diff --git a/application/single_app/app.py b/application/single_app/app.py index 711931da..d8561195 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -557,8 +557,8 @@ def inject_settings(): try: user_id = get_current_user_id() if user_id: - from functions_settings import get_user_settings - user_settings = get_user_settings(user_id) or {} + from functions_settings import get_user_ui_settings + user_settings = get_user_ui_settings(user_id) or {} except Exception as e: print(f"Error injecting user settings: {e}") log_event(f"Error injecting user settings: {e}", level=logging.ERROR) diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index 44e28367..5f3f24e4 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -6,6 +6,7 @@ """ import json import logging +import copy import threading import time from datetime import datetime @@ -19,6 +20,7 @@ _settings = None _logger = logging.getLogger(__name__) APP_SETTINGS_CACHE = {} +APP_USER_UI_SETTINGS_CACHE = {} APP_STREAM_SESSION_METADATA = {} APP_STREAM_SESSION_EVENTS = {} APP_SETTINGS_CACHE_VERSION = 0 @@ -28,6 +30,8 @@ APP_SETTINGS_CACHE_KEY = 'APP_SETTINGS_CACHE' APP_SETTINGS_CACHE_VERSION_KEY = 'APP_SETTINGS_CACHE_VERSION' APP_SETTINGS_CACHE_VERSION_DOC_ID = 'app_settings_cache_version' +USER_UI_SETTINGS_CACHE_KEY_PREFIX = 'USER_UI_SETTINGS' +USER_UI_SETTINGS_CACHE_TTL_SECONDS = 120 GOVERNANCE_CACHE_VERSION_KEY = 'GOVERNANCE_CACHE_VERSION' GOVERNANCE_CACHE_VERSION_DOC_ID = 'governance_cache_version' CACHE_VERSION_DOC_TYPE = 'cache_version' @@ -42,6 +46,9 @@ append_stream_session_event = None get_stream_session_events = None delete_stream_session_cache = None +get_user_ui_settings_cache = None +set_user_ui_settings_cache = None +delete_user_ui_settings_cache = None get_governance_cache_version = None bump_governance_cache_version = None app_cache_is_using_redis = False @@ -119,11 +126,12 @@ def _set_ttl_cached_version(version_cache, version): def configure_app_cache(settings, redis_cache_endpoint=None): global _settings, update_settings_cache, get_settings_cache, APP_SETTINGS_CACHE - global APP_STREAM_SESSION_METADATA, APP_STREAM_SESSION_EVENTS + global APP_USER_UI_SETTINGS_CACHE, APP_STREAM_SESSION_METADATA, APP_STREAM_SESSION_EVENTS global APP_SETTINGS_CACHE_VERSION, APP_GOVERNANCE_CACHE_VERSION global APP_SETTINGS_SHARED_VERSION_CACHE, APP_GOVERNANCE_SHARED_VERSION_CACHE global initialize_stream_session_cache, set_stream_session_meta, get_stream_session_meta global append_stream_session_event, get_stream_session_events, delete_stream_session_cache + global get_user_ui_settings_cache, set_user_ui_settings_cache, delete_user_ui_settings_cache global get_app_settings_cache_version, bump_app_settings_cache_version global get_governance_cache_version, bump_governance_cache_version global app_cache_is_using_redis @@ -288,6 +296,24 @@ def delete_stream_session_cache_redis(cache_key): get_stream_session_events_key(cache_key), ) + def get_user_ui_settings_cache_key(user_id): + return f'{USER_UI_SETTINGS_CACHE_KEY_PREFIX}:{user_id}' + + def get_user_ui_settings_cache_redis(user_id): + cached = redis_client.get(get_user_ui_settings_cache_key(user_id)) + return json.loads(cached) if cached else None + + def set_user_ui_settings_cache_redis(user_id, ui_settings, ttl_seconds=None): + ttl = int(ttl_seconds or USER_UI_SETTINGS_CACHE_TTL_SECONDS) + redis_client.setex( + get_user_ui_settings_cache_key(user_id), + ttl, + json.dumps(ui_settings or {}) + ) + + def delete_user_ui_settings_cache_redis(user_id): + redis_client.delete(get_user_ui_settings_cache_key(user_id)) + def get_governance_cache_version_redis(): cached = redis_client.get(GOVERNANCE_CACHE_VERSION_KEY) if cached is None: @@ -308,6 +334,9 @@ def bump_governance_cache_version_redis(): append_stream_session_event = append_stream_session_event_redis get_stream_session_events = get_stream_session_events_redis delete_stream_session_cache = delete_stream_session_cache_redis + get_user_ui_settings_cache = get_user_ui_settings_cache_redis + set_user_ui_settings_cache = set_user_ui_settings_cache_redis + delete_user_ui_settings_cache = delete_user_ui_settings_cache_redis get_governance_cache_version = get_governance_cache_version_redis bump_governance_cache_version = bump_governance_cache_version_redis @@ -408,6 +437,28 @@ def delete_stream_session_cache_mem(cache_key): APP_STREAM_SESSION_METADATA.pop(cache_key, None) APP_STREAM_SESSION_EVENTS.pop(cache_key, None) + def get_user_ui_settings_cache_mem(user_id): + with _app_cache_lock: + entry = APP_USER_UI_SETTINGS_CACHE.get(user_id) + if _is_expired(entry): + APP_USER_UI_SETTINGS_CACHE.pop(user_id, None) + return None + return copy.deepcopy(entry.get('value') or {}) + + def set_user_ui_settings_cache_mem(user_id, ui_settings, ttl_seconds=None): + expiration_timestamp = _get_expiration_timestamp( + ttl_seconds or USER_UI_SETTINGS_CACHE_TTL_SECONDS + ) + with _app_cache_lock: + APP_USER_UI_SETTINGS_CACHE[user_id] = { + 'value': copy.deepcopy(ui_settings or {}), + 'expires_at': expiration_timestamp, + } + + def delete_user_ui_settings_cache_mem(user_id): + with _app_cache_lock: + APP_USER_UI_SETTINGS_CACHE.pop(user_id, None) + def get_app_settings_cache_version_mem(): global APP_SETTINGS_CACHE_VERSION try: @@ -494,5 +545,8 @@ def bump_governance_cache_version_mem(): append_stream_session_event = append_stream_session_event_mem get_stream_session_events = get_stream_session_events_mem delete_stream_session_cache = delete_stream_session_cache_mem + get_user_ui_settings_cache = get_user_ui_settings_cache_mem + set_user_ui_settings_cache = set_user_ui_settings_cache_mem + delete_user_ui_settings_cache = delete_user_ui_settings_cache_mem get_governance_cache_version = get_governance_cache_version_mem bump_governance_cache_version = bump_governance_cache_version_mem \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index d5fc1498..eccf5e18 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.242.043" +VERSION = "0.242.044" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 8f5a65e0..9bc2c7c2 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1,6 +1,6 @@ # functions_settings.py -from flask import has_request_context +from flask import g, has_request_context from config import * from functions_appinsights import log_event @@ -14,6 +14,101 @@ ) +USER_SETTINGS_REQUEST_CACHE_ATTR = "simplechat_user_settings_request_cache" +USER_UI_SETTINGS_KEYS = ( + "profileImage", + "navLayout", + "darkModeEnabled", + "showTutorialButtons", + "chatLayout", + "streamingEnabled", + "notifications_per_page", +) + + +def _clone_user_settings_doc(doc): + return copy.deepcopy(doc or {}) + + +def _get_user_settings_request_cache(): + if not has_request_context(): + return None + + cache = getattr(g, USER_SETTINGS_REQUEST_CACHE_ATTR, None) + if cache is None: + cache = {} + setattr(g, USER_SETTINGS_REQUEST_CACHE_ATTR, cache) + return cache + + +def _get_request_cached_user_settings(user_id): + cache = _get_user_settings_request_cache() + if cache is None or user_id not in cache: + return None + return _clone_user_settings_doc(cache[user_id]) + + +def _set_request_cached_user_settings(user_id, doc): + cache = _get_user_settings_request_cache() + if cache is not None: + cache[user_id] = _clone_user_settings_doc(doc) + + +def _delete_request_cached_user_settings(user_id): + cache = _get_user_settings_request_cache() + if cache is not None: + cache.pop(user_id, None) + + +def _extract_user_ui_settings(doc): + settings = (doc or {}).get('settings', {}) + if not isinstance(settings, dict): + settings = {} + return { + key: copy.deepcopy(settings[key]) + for key in USER_UI_SETTINGS_KEYS + if key in settings + } + + +def _delete_user_ui_settings_cache(user_id): + cache_deleter = getattr(app_settings_cache, "delete_user_ui_settings_cache", None) + if callable(cache_deleter): + try: + cache_deleter(user_id) + except Exception as cache_error: + log_event( + "[UserSettingsCache] Failed to delete user UI settings cache.", + extra={ + "user_id": user_id, + "error": str(cache_error) + }, + level=logging.WARNING + ) + + +def _set_user_ui_settings_cache(user_id, doc): + cache_setter = getattr(app_settings_cache, "set_user_ui_settings_cache", None) + if callable(cache_setter): + try: + cache_setter(user_id, _extract_user_ui_settings(doc)) + except Exception as cache_error: + log_event( + "[UserSettingsCache] Failed to set user UI settings cache.", + extra={ + "user_id": user_id, + "error": str(cache_error) + }, + level=logging.WARNING + ) + + +def invalidate_user_settings_caches(user_id): + """Clear request and lightweight UI caches for a user settings document.""" + _delete_request_cached_user_settings(user_id) + _delete_user_ui_settings_cache(user_id) + + def is_tabular_processing_enabled(settings): """Tabular processing is available whenever enhanced citations is enabled.""" return bool((settings or {}).get('enable_enhanced_citations', False)) @@ -54,17 +149,45 @@ def _should_sync_session_profile(target_user_id, actor_user_id, allow_cross_user normalized_target_user_id = str(target_user_id or '').strip() normalized_actor_user_id = str(actor_user_id or '').strip() return bool(normalized_target_user_id and normalized_actor_user_id and normalized_target_user_id == normalized_actor_user_id) -import copy -from support_menu_config import ( - get_default_support_latest_features_visibility, - has_visible_support_latest_features, - normalize_support_latest_features_visibility, -) -def is_tabular_processing_enabled(settings): - """Tabular processing is available whenever enhanced citations is enabled.""" - return bool((settings or {}).get('enable_enhanced_citations', False)) +def _refresh_app_settings_cache_after_write(settings_payload, context="app_settings_write"): + """Update shared/local settings cache around a version bump.""" + cache_updater = getattr(app_settings_cache, "update_settings_cache", None) + version_bumper = getattr(app_settings_cache, "bump_app_settings_cache_version", None) + + def _update_cache(stage): + if not callable(cache_updater): + return + try: + cache_updater(copy.deepcopy(settings_payload)) + except Exception as cache_error: + log_event( + "App settings cache update failed after settings write.", + extra={ + "context": context, + "stage": stage, + "error": str(cache_error) + }, + level=logging.WARNING + ) + + _update_cache("before_version_bump") + + if callable(version_bumper): + try: + version_bumper() + except Exception as version_error: + log_event( + "App settings cache version bump failed after settings write.", + extra={ + "context": context, + "error": str(version_error) + }, + level=logging.WARNING + ) + + _update_cache("after_version_bump") def _refresh_app_settings_cache_after_write(settings_payload, context="app_settings_write"): @@ -1129,6 +1252,11 @@ def get_user_settings(user_id, allow_cross_user=False): actor_user_id, allow_cross_user=allow_cross_user, ) + + cached_doc = _get_request_cached_user_settings(user_id) + if cached_doc is not None: + return cached_doc + try: doc = cosmos_user_settings_container.read_item(item=user_id, partition_key=user_id) updated = False @@ -1182,7 +1310,10 @@ def get_user_settings(user_id, allow_cross_user=False): if updated: cosmos_user_settings_container.upsert_item(body=doc) - return doc + _set_user_ui_settings_cache(user_id, doc) + + _set_request_cached_user_settings(user_id, doc) + return _clone_user_settings_doc(doc) except exceptions.CosmosResourceNotFoundError: # Return a default structure if the user has no settings saved yet doc = {"id": user_id, "settings": {}} @@ -1214,7 +1345,9 @@ def get_user_settings(user_id, allow_cross_user=False): doc['settings']['profileImage'] = None cosmos_user_settings_container.upsert_item(body=doc) - return doc + _set_user_ui_settings_cache(user_id, doc) + _set_request_cached_user_settings(user_id, doc) + return _clone_user_settings_doc(doc) except Exception as e: log_event( "Error retrieving user settings.", @@ -1226,6 +1359,44 @@ def get_user_settings(user_id, allow_cross_user=False): exceptionTraceback=True ) raise # Re-raise the exception to be handled by the route + + +def get_user_ui_settings(user_id, allow_cross_user=False): + """Return a lightweight, cacheable subset of user settings used by shared page chrome.""" + _authorize_user_settings_access(user_id, "read UI settings", allow_cross_user=allow_cross_user) + + cached_doc = _get_request_cached_user_settings(user_id) + if cached_doc is not None: + return { + 'id': user_id, + 'settings': _extract_user_ui_settings(cached_doc), + } + + cache_getter = getattr(app_settings_cache, "get_user_ui_settings_cache", None) + if callable(cache_getter): + try: + cached_ui_settings = cache_getter(user_id) + if cached_ui_settings is not None: + return { + 'id': user_id, + 'settings': copy.deepcopy(cached_ui_settings or {}), + } + except Exception as cache_error: + log_event( + "[UserSettingsCache] Failed to read user UI settings cache.", + extra={ + "user_id": user_id, + "error": str(cache_error) + }, + level=logging.WARNING + ) + + doc = get_user_settings(user_id, allow_cross_user=allow_cross_user) + _set_user_ui_settings_cache(user_id, doc) + return { + 'id': user_id, + 'settings': _extract_user_ui_settings(doc), + } def update_user_settings(user_id, settings_to_update, allow_cross_user=False): """ @@ -1382,6 +1553,8 @@ def update_user_settings(user_id, settings_to_update, allow_cross_user=False): # Upsert the modified document cosmos_user_settings_container.upsert_item(body=doc) # Use body=doc for clarity + _set_request_cached_user_settings(user_id, doc) + _delete_user_ui_settings_cache(user_id) return True @@ -1550,6 +1723,7 @@ def add_search_to_history(user_id, search_term): doc['search_history'] = search_history cosmos_user_settings_container.upsert_item(body=doc) + invalidate_user_settings_caches(user_id) return search_history except Exception as e: @@ -1574,6 +1748,7 @@ def clear_user_search_history(user_id): doc['search_history'] = [] cosmos_user_settings_container.upsert_item(body=doc) + invalidate_user_settings_caches(user_id) return True except Exception as e: diff --git a/application/single_app/route_backend_retention_policy.py b/application/single_app/route_backend_retention_policy.py index 60935f60..49320755 100644 --- a/application/single_app/route_backend_retention_policy.py +++ b/application/single_app/route_backend_retention_policy.py @@ -280,6 +280,7 @@ def force_push_retention_defaults(): user['settings'] = user_settings cosmos_user_settings_container.upsert_item(user) + invalidate_user_settings_caches(user_id) personal_count += 1 except Exception as e: debug_print(f"Error updating user {user_id}: {e}") diff --git a/application/single_app/static/js/dark-mode.js b/application/single_app/static/js/dark-mode.js index 1fb06b2e..c927597b 100644 --- a/application/single_app/static/js/dark-mode.js +++ b/application/single_app/static/js/dark-mode.js @@ -30,6 +30,9 @@ async function saveDarkModeSetting(settingsToUpdate) { if (!response.ok) { console.error('Failed to save dark mode setting:', response.statusText); } else { + if (window.simplechatUserSettings && typeof window.simplechatUserSettings === 'object') { + Object.assign(window.simplechatUserSettings, settingsToUpdate); + } console.log('Dark mode setting saved successfully'); } } catch (error) { @@ -37,6 +40,13 @@ async function saveDarkModeSetting(settingsToUpdate) { } } +function getInjectedUserSettings() { + if (window.simplechatUserSettings && typeof window.simplechatUserSettings === 'object') { + return window.simplechatUserSettings; + } + return {}; +} + // Function to toggle dark mode function toggleDarkMode(e) { e && e.preventDefault && e.preventDefault(); @@ -90,21 +100,24 @@ async function loadDarkModePreference() { localTheme = 'dark'; } - // Sync with server - which may override localStorage if user has multiple devices - const response = await fetch('/api/user/settings'); - if (response.ok) { - const data = await response.json(); - const settings = data.settings || {}; + // Sync with server-provided settings first; fetch only when the page did not inject them. + let settings = getInjectedUserSettings(); + if (!(USER_SETTINGS_KEY_DARK_MODE in settings)) { + const response = await fetch('/api/user/settings'); + if (response.ok) { + const data = await response.json(); + settings = data.settings || {}; + } + } + + // If user has a saved preference in their account, use it and update localStorage + if (USER_SETTINGS_KEY_DARK_MODE in settings) { + const serverTheme = settings[USER_SETTINGS_KEY_DARK_MODE] === true ? 'dark' : 'light'; - // If user has a saved preference in their account, use it and update localStorage - if (USER_SETTINGS_KEY_DARK_MODE in settings) { - const serverTheme = settings[USER_SETTINGS_KEY_DARK_MODE] === true ? 'dark' : 'light'; - - // Update localStorage if server setting differs - if (!localTheme || serverTheme !== localTheme) { - localStorage.setItem(LOCAL_STORAGE_THEME_KEY, serverTheme); - localTheme = serverTheme; - } + // Update localStorage if server setting differs + if (!localTheme || serverTheme !== localTheme) { + localStorage.setItem(LOCAL_STORAGE_THEME_KEY, serverTheme); + localTheme = serverTheme; } } @@ -141,6 +154,7 @@ if (typeof module !== 'undefined' && module.exports) { loadDarkModePreference, getAllDarkModeToggles, getToggleParts, - saveDarkModeSetting + saveDarkModeSetting, + getInjectedUserSettings }; } \ No newline at end of file diff --git a/application/single_app/static/js/sidebar.js b/application/single_app/static/js/sidebar.js index 31b7b088..34e3823f 100644 --- a/application/single_app/static/js/sidebar.js +++ b/application/single_app/static/js/sidebar.js @@ -10,6 +10,10 @@ // Utility functions for user settings async function getUserSettings() { + if (window.simplechatUserSettings && typeof window.simplechatUserSettings === 'object') { + return window.simplechatUserSettings; + } + try { const resp = await fetch('/api/user/settings'); if (!resp.ok) return {}; @@ -23,11 +27,14 @@ async function getUserSettings() { async function setUserNavLayout(navLayout) { try { - await fetch('/api/user/settings', { + const resp = await fetch('/api/user/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: { navLayout } }) }); + if (resp.ok && window.simplechatUserSettings && typeof window.simplechatUserSettings === 'object') { + window.simplechatUserSettings.navLayout = navLayout; + } console.log('Nav layout setting saved successfully:', navLayout); } catch (e) { console.error('Error saving nav layout setting:', e); diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html index f3d35195..ff8069a4 100644 --- a/application/single_app/templates/base.html +++ b/application/single_app/templates/base.html @@ -345,15 +345,16 @@ 'displayName': session.get('user', {}).get('name'), 'email': session.get('user', {}).get('preferred_username'), 'roles': session.get('user', {}).get('roles', []) - } | tojson | safe }}; + } | tojson }}; window.currentUserIsAdmin = Array.isArray(window.userContext?.roles) ? window.userContext.roles.includes('Admin') : false; if (!window.current_user_id && window.userContext?.id) { window.current_user_id = window.userContext.id; } - const appSettings = {{ app_settings|tojson|safe }}; + const appSettings = {{ app_settings|tojson }}; window.appSettings = appSettings; + window.simplechatUserSettings = {{ user_settings.get('settings', {}) | tojson }}; @@ -415,7 +416,7 @@ 'heartbeatUrl': url_for('session_heartbeat'), 'localLogoutUrl': url_for('local_logout'), 'fullSsoLogoutUrl': url_for('logout') - } | tojson | safe }}; + } | tojson }}; {% endif %} diff --git a/docs/explanation/features/USER_SETTINGS_CACHE_OPTIMIZATION.md b/docs/explanation/features/USER_SETTINGS_CACHE_OPTIMIZATION.md new file mode 100644 index 00000000..4f6a8f90 --- /dev/null +++ b/docs/explanation/features/USER_SETTINGS_CACHE_OPTIMIZATION.md @@ -0,0 +1,89 @@ +# User Settings Cache Optimization + +Implemented in version: **0.242.044** + +## Overview + +User settings cache optimization reduces repeated Cosmos DB reads for user-specific UI preferences while keeping security-sensitive user settings fresh. The implementation supports both single App Service deployments without Redis and scaled-out App Service deployments with Redis. + +## Purpose + +SimpleChat reads user settings during shared page rendering and from frontend scripts that initialize theme, navigation, profile image, and tutorial controls. Without caching, a single page load can read the same user settings document multiple times. + +This feature adds request-scoped memoization for full user settings and a lightweight UI settings cache for non-security browser chrome preferences. + +## Dependencies + +- Flask request context for request-scoped memoization +- Existing app cache configuration in `application/single_app/app_settings_cache.py` +- Optional Redis cache when `enable_redis_cache` is enabled +- Cosmos DB user settings container as the source of truth + +## Technical Specifications + +### Cache Layers + +1. Request-scoped cache for full user settings documents. +2. Lightweight UI settings cache for shared page chrome fields. +3. Redis-backed UI settings cache when Redis is enabled. +4. Process-local TTL UI settings cache when Redis is disabled. + +### Cached UI Fields + +The lightweight UI cache includes fields such as: + +- `profileImage` +- `navLayout` +- `darkModeEnabled` +- `showTutorialButtons` +- `chatLayout` +- `streamingEnabled` +- `notifications_per_page` + +Security-sensitive fields such as access restrictions and file upload controls are not introduced into the lightweight UI cache contract. + +### No-Redis Behavior + +In a single App Service deployment without Redis, the UI settings cache is process-local with a short TTL. It does not use per-user Cosmos version documents, avoiding high-cardinality invalidation reads across multiple workers. + +### Redis Behavior + +In scaled-out deployments with Redis, the UI settings cache uses Redis keys shared by all workers and instances. User settings writes invalidate the lightweight UI cache so later page renders reload current values from Cosmos. + +## Primary Files + +- `application/single_app/app_settings_cache.py` +- `application/single_app/functions_settings.py` +- `application/single_app/app.py` +- `application/single_app/templates/base.html` +- `application/single_app/static/js/dark-mode.js` +- `application/single_app/static/js/sidebar.js` +- `functional_tests/test_user_settings_cache_optimization.py` + +## Usage Instructions + +No administrator action is required. The cache behavior follows the existing app Redis configuration: + +- Redis disabled: request cache plus process-local UI settings TTL cache. +- Redis enabled: request cache plus Redis-backed UI settings cache. + +## Testing and Validation + +Functional coverage is provided by `functional_tests/test_user_settings_cache_optimization.py`. + +The test validates: + +- Full user settings request memoization markers. +- Redis and no-Redis UI settings cache contracts. +- Cache invalidation markers on user settings write paths. +- Frontend reuse of injected user UI settings before full API fallback. + +## Known Limitations + +The no-Redis process-local UI cache is intentionally short-lived and per worker. It is designed for low-risk UI preferences, not immediate cross-worker propagation of security decisions. + +## Related Version Updates + +Implemented in version: **0.242.044** + +The application version was updated in `application/single_app/config.py` to track this feature. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index aac14500..33008100 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -4,6 +4,15 @@ This page tracks notable Simple Chat releases and organizes the detailed change For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.242.044)** + +#### New Features + +* **User Settings Cache Optimization** + * Added request-scoped memoization for full user settings reads and a lightweight user UI settings cache that works with Redis-enabled and no-Redis deployments. + * Shared page scripts now reuse injected UI preferences for dark mode and navigation layout before falling back to the full user settings API. + * (Ref: user settings cache, user UI settings cache, `functions_settings.py`, `app_settings_cache.py`, `dark-mode.js`, `sidebar.js`) + ### **(v0.242.033)** #### New Features diff --git a/functional_tests/test_user_settings_cache_optimization.py b/functional_tests/test_user_settings_cache_optimization.py new file mode 100644 index 00000000..1e8b8ff7 --- /dev/null +++ b/functional_tests/test_user_settings_cache_optimization.py @@ -0,0 +1,155 @@ +# test_user_settings_cache_optimization.py +#!/usr/bin/env python3 +""" +Functional test for user settings cache optimization. +Version: 0.242.044 +Implemented in: 0.242.044 + +This test ensures user settings reads use request-scoped caching, shared UI settings +caches support Redis and no-Redis deployments, and frontend scripts reuse injected +user UI settings before fetching the full user settings document. +""" + +import os +import sys + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SINGLE_APP_DIR = os.path.join(ROOT_DIR, "application", "single_app") +if SINGLE_APP_DIR not in sys.path: + sys.path.append(SINGLE_APP_DIR) + + +def _read(*parts): + path = os.path.join(ROOT_DIR, *parts) + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def test_user_settings_request_cache_contract(): + """Validate full user settings reads use request-scoped memoization.""" + print("Testing user settings request cache contract...") + + settings_content = _read("application", "single_app", "functions_settings.py") + + for marker in [ + "USER_SETTINGS_REQUEST_CACHE_ATTR", + "_get_user_settings_request_cache()", + "_get_request_cached_user_settings(user_id)", + "_set_request_cached_user_settings(user_id, doc)", + "_delete_request_cached_user_settings(user_id)", + "return _clone_user_settings_doc(doc)", + ]: + assert marker in settings_content, f"Missing request cache marker: {marker}" + + assert "cached_doc = _get_request_cached_user_settings(user_id)" in settings_content, ( + "Expected get_user_settings to check the request cache before Cosmos" + ) + + print("PASS: user settings request cache contract verified") + + +def test_user_ui_settings_cache_contract(): + """Validate UI settings cache supports Redis and process-local fallback.""" + print("Testing user UI settings cache contract...") + + cache_content = _read("application", "single_app", "app_settings_cache.py") + settings_content = _read("application", "single_app", "functions_settings.py") + + for marker in [ + "APP_USER_UI_SETTINGS_CACHE", + "USER_UI_SETTINGS_CACHE_KEY_PREFIX", + "USER_UI_SETTINGS_CACHE_TTL_SECONDS = 120", + "get_user_ui_settings_cache_redis", + "set_user_ui_settings_cache_redis", + "delete_user_ui_settings_cache_redis", + "get_user_ui_settings_cache_mem", + "set_user_ui_settings_cache_mem", + "delete_user_ui_settings_cache_mem", + ]: + assert marker in cache_content, f"Missing user UI cache marker: {marker}" + + for marker in [ + "USER_UI_SETTINGS_KEYS", + "def get_user_ui_settings(user_id, allow_cross_user=False):", + "_extract_user_ui_settings(doc)", + "invalidate_user_settings_caches(user_id)", + "_delete_user_ui_settings_cache(user_id)", + ]: + assert marker in settings_content, f"Missing user UI settings marker: {marker}" + + assert "APP_SETTINGS_CACHE_VERSION_DOC_ID" in cache_content, "Expected existing app settings versioning to remain" + assert "USER_UI_SETTINGS_CACHE_VERSION_DOC_ID" not in cache_content, ( + "User UI settings cache should not add per-user Cosmos version documents" + ) + + print("PASS: user UI settings cache contract verified") + + +def test_user_settings_write_invalidation_contract(): + """Validate user settings writes invalidate lightweight UI caches.""" + print("Testing user settings write invalidation contract...") + + settings_content = _read("application", "single_app", "functions_settings.py") + retention_content = _read("application", "single_app", "route_backend_retention_policy.py") + + assert "_set_request_cached_user_settings(user_id, doc)" in settings_content, ( + "Expected update_user_settings to refresh request cache after writes" + ) + assert "_delete_user_ui_settings_cache(user_id)" in settings_content, ( + "Expected update_user_settings to clear UI cache after writes" + ) + assert settings_content.count("invalidate_user_settings_caches(user_id)") >= 2, ( + "Expected direct search-history upserts to invalidate user settings caches" + ) + assert "invalidate_user_settings_caches(user_id)" in retention_content, ( + "Expected retention force-push direct upserts to invalidate user settings caches" + ) + + print("PASS: user settings write invalidation contract verified") + + +def test_frontend_reuses_injected_user_ui_settings(): + """Validate shared scripts use injected UI settings before API fallback.""" + print("Testing frontend injected user UI settings reuse...") + + base_content = _read("application", "single_app", "templates", "base.html") + dark_mode_content = _read("application", "single_app", "static", "js", "dark-mode.js") + sidebar_content = _read("application", "single_app", "static", "js", "sidebar.js") + + assert "window.simplechatUserSettings" in base_content, ( + "Expected base template to expose lightweight user UI settings to scripts" + ) + assert "getInjectedUserSettings()" in dark_mode_content, ( + "Expected dark mode script to read injected user settings" + ) + assert "if (!(USER_SETTINGS_KEY_DARK_MODE in settings))" in dark_mode_content, ( + "Expected dark mode script to fetch full settings only when injected data is missing" + ) + assert "window.simplechatUserSettings && typeof window.simplechatUserSettings === 'object'" in sidebar_content, ( + "Expected sidebar script to reuse injected user settings before API fallback" + ) + + print("PASS: frontend injected user UI settings reuse verified") + + +if __name__ == "__main__": + tests = [ + test_user_settings_request_cache_contract, + test_user_ui_settings_cache_contract, + test_user_settings_write_invalidation_contract, + test_frontend_reuses_injected_user_ui_settings, + ] + results = [] + + for test in tests: + try: + test() + results.append(True) + except Exception as exc: + print(f"FAIL: {test.__name__} -> {exc}") + results.append(False) + + passed = sum(1 for result in results if result) + print(f"Results: {passed}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1)