diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..af044401 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,103 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "Development" ] + pull_request: + branches: [ "Development" ] + workflow_dispatch: + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: +security-and-quality + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore index e8e2d544..969720f2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ scripts/agent.json scripts/me.json .github/instructions/python-venv-path.instructions.md .github/instructions/local.instructions.md +/venv deploy.zip diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index 90dbc4a6..7067bfb3 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -11,7 +11,7 @@ import os import threading import time -from datetime import datetime +from datetime import datetime, timedelta from redis import Redis from redis.credentials import CredentialProvider from azure.identity import DefaultAzureCredential @@ -32,6 +32,7 @@ APP_GOVERNANCE_CACHE_VERSION = 0 APP_SETTINGS_SHARED_VERSION_CACHE = {'value': 0, 'expires_at': 0} APP_GOVERNANCE_SHARED_VERSION_CACHE = {'value': 0, 'expires_at': 0} +APP_REDIS_CLIENT = None 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' @@ -41,6 +42,8 @@ GOVERNANCE_CACHE_VERSION_DOC_ID = 'governance_cache_version' CACHE_VERSION_DOC_TYPE = 'cache_version' CACHE_VERSION_READ_TTL_SECONDS = 15 +COSMOS_CACHE_ENTRY_DOC_TYPE = 'app_cache_entry' +COSMOS_CACHE_ENTRY_PREFIX = 'app_cache_entry:' update_settings_cache = None get_settings_cache = None get_app_settings_cache_version = None @@ -188,6 +191,495 @@ def _set_ttl_cached_version(version_cache, version): version_cache['value'] = _normalize_cache_version(version) version_cache['expires_at'] = time.time() + CACHE_VERSION_READ_TTL_SECONDS + +def _log_cache_fallback(operation, exception, log_event_func=None): + message = f"[ASC] Redis cache operation failed; using Cosmos/local fallback for {operation}." + _logger.warning("%s Error: %s", message, exception) + if callable(log_event_func): + log_event_func( + message, + extra={ + 'operation': operation, + 'error': str(exception), + }, + level=logging.WARNING, + ) + + +def _build_cosmos_cache_doc_id(cache_key): + return f"{COSMOS_CACHE_ENTRY_PREFIX}{cache_key}" + + +def _get_cosmos_cache_container(): + from config import cosmos_settings_container + return cosmos_settings_container + + +def _serialize_datetime(value): + if isinstance(value, datetime): + return value.isoformat() + return value + + +def _set_cosmos_cache_entry(cache_key, payload, ttl_seconds=None, log_event_func=None): + try: + now = datetime.utcnow() + expires_at = None + if ttl_seconds is not None: + expires_at = now + timedelta(seconds=max(int(ttl_seconds), 0)) + doc_id = _build_cosmos_cache_doc_id(cache_key) + _get_cosmos_cache_container().upsert_item({ + 'id': doc_id, + 'type': COSMOS_CACHE_ENTRY_DOC_TYPE, + 'cache_key': cache_key, + 'payload': copy.deepcopy(payload), + 'created_at': now.isoformat(), + 'updated_at': now.isoformat(), + 'expires_at': _serialize_datetime(expires_at), + }) + return True + except Exception as ex: + _logger.warning("[ASC] Cosmos cache fallback write failed for %s: %s", cache_key, ex) + if callable(log_event_func): + log_event_func( + "[ASC] Cosmos cache fallback write failed.", + extra={'cache_key': cache_key, 'error': str(ex)}, + level=logging.WARNING, + ) + return False + + +def _get_cosmos_cache_entry(cache_key, log_event_func=None): + try: + doc_id = _build_cosmos_cache_doc_id(cache_key) + doc = _get_cosmos_cache_container().read_item(item=doc_id, partition_key=doc_id) + expires_at = doc.get('expires_at') + if expires_at: + try: + if datetime.fromisoformat(expires_at) <= datetime.utcnow(): + return None + except (TypeError, ValueError): + return None + return copy.deepcopy(doc.get('payload')) + except Exception as ex: + status_code = getattr(ex, 'status_code', None) + if status_code != 404: + _logger.warning("[ASC] Cosmos cache fallback read failed for %s: %s", cache_key, ex) + if callable(log_event_func): + log_event_func( + "[ASC] Cosmos cache fallback read failed.", + extra={'cache_key': cache_key, 'error': str(ex), 'status_code': status_code}, + level=logging.WARNING, + ) + return None + + +def _delete_cosmos_cache_entry(cache_key, log_event_func=None): + try: + doc_id = _build_cosmos_cache_doc_id(cache_key) + _get_cosmos_cache_container().delete_item(item=doc_id, partition_key=doc_id) + return True + except Exception as ex: + status_code = getattr(ex, 'status_code', None) + if status_code != 404: + _logger.warning("[ASC] Cosmos cache fallback delete failed for %s: %s", cache_key, ex) + if callable(log_event_func): + log_event_func( + "[ASC] Cosmos cache fallback delete failed.", + extra={'cache_key': cache_key, 'error': str(ex), 'status_code': status_code}, + level=logging.WARNING, + ) + return False + + +def _get_app_settings_cache_version_fallback(log_event_func=None): + global APP_SETTINGS_CACHE_VERSION + try: + from config import cosmos_settings_container + return _get_ttl_cached_cosmos_version( + APP_SETTINGS_SHARED_VERSION_CACHE, + cosmos_settings_container, + APP_SETTINGS_CACHE_VERSION_DOC_ID, + APP_SETTINGS_CACHE_VERSION, + log_event_func=log_event_func, + ) + except Exception as ex: + _logger.warning("[ASC] Shared cache version read failed; using local version fallback: %s", ex) + if callable(log_event_func): + log_event_func( + "[ASC] Shared cache version read failed; using local version fallback.", + extra={'version_doc_id': APP_SETTINGS_CACHE_VERSION_DOC_ID, 'error': str(ex)}, + level=logging.WARNING, + ) + with _app_cache_lock: + return APP_SETTINGS_CACHE_VERSION + + +def _bump_app_settings_cache_version_fallback(log_event_func=None): + global APP_SETTINGS_CACHE_VERSION + try: + from config import cosmos_settings_container + bumped_version = _bump_cosmos_cache_version( + cosmos_settings_container, + APP_SETTINGS_CACHE_VERSION_DOC_ID, + log_event_func=log_event_func, + ) + if bumped_version is not None: + with _app_cache_lock: + APP_SETTINGS_CACHE_VERSION = bumped_version + _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, bumped_version) + return bumped_version + except Exception as ex: + _logger.warning("[ASC] Shared cache version bump failed; using local version fallback: %s", ex) + if callable(log_event_func): + log_event_func( + "[ASC] Shared cache version bump failed; using local version fallback.", + extra={'version_doc_id': APP_SETTINGS_CACHE_VERSION_DOC_ID, 'error': str(ex)}, + level=logging.WARNING, + ) + + with _app_cache_lock: + APP_SETTINGS_CACHE_VERSION += 1 + fallback_version = APP_SETTINGS_CACHE_VERSION + _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, fallback_version) + return fallback_version + + +def _update_settings_cache_fallback(new_settings, log_event_func=None): + global APP_SETTINGS_CACHE, APP_SETTINGS_CACHE_VERSION + shared_version = _get_app_settings_cache_version_fallback(log_event_func=log_event_func) + with _app_cache_lock: + APP_SETTINGS_CACHE = copy.deepcopy(new_settings or {}) + APP_SETTINGS_CACHE_VERSION = shared_version + + +def _get_settings_cache_fallback(log_event_func=None): + global APP_SETTINGS_CACHE, APP_SETTINGS_CACHE_VERSION + shared_version = _get_app_settings_cache_version_fallback(log_event_func=log_event_func) + with _app_cache_lock: + if APP_SETTINGS_CACHE and APP_SETTINGS_CACHE_VERSION == shared_version: + return copy.deepcopy(APP_SETTINGS_CACHE) + + try: + from config import cosmos_settings_container + loaded_settings = cosmos_settings_container.read_item( + item='app_settings', + partition_key='app_settings', + ) + with _app_cache_lock: + APP_SETTINGS_CACHE = copy.deepcopy(loaded_settings or {}) + APP_SETTINGS_CACHE_VERSION = shared_version + return copy.deepcopy(loaded_settings or {}) + except Exception as ex: + _logger.warning("[ASC] Failed to refresh app settings cache from Cosmos; using local cache fallback: %s", ex) + if callable(log_event_func): + log_event_func( + "[ASC] Failed to refresh app settings cache from Cosmos; using local cache fallback.", + extra={'error': str(ex)}, + level=logging.WARNING, + ) + with _app_cache_lock: + return copy.deepcopy(APP_SETTINGS_CACHE) + + +def _get_governance_cache_version_fallback(log_event_func=None): + global APP_GOVERNANCE_CACHE_VERSION + try: + from config import cosmos_governance_policies_container + return _get_ttl_cached_cosmos_version( + APP_GOVERNANCE_SHARED_VERSION_CACHE, + cosmos_governance_policies_container, + GOVERNANCE_CACHE_VERSION_DOC_ID, + APP_GOVERNANCE_CACHE_VERSION, + log_event_func=log_event_func, + ) + except Exception as ex: + _logger.warning("[ASC] Governance cache version read failed; using local version fallback: %s", ex) + if callable(log_event_func): + log_event_func( + "[ASC] Governance cache version read failed; using local version fallback.", + extra={'version_doc_id': GOVERNANCE_CACHE_VERSION_DOC_ID, 'error': str(ex)}, + level=logging.WARNING, + ) + with _app_cache_lock: + return APP_GOVERNANCE_CACHE_VERSION + + +def _bump_governance_cache_version_fallback(log_event_func=None): + global APP_GOVERNANCE_CACHE_VERSION + try: + from config import cosmos_governance_policies_container + bumped_version = _bump_cosmos_cache_version( + cosmos_governance_policies_container, + GOVERNANCE_CACHE_VERSION_DOC_ID, + log_event_func=log_event_func, + ) + if bumped_version is not None: + with _app_cache_lock: + APP_GOVERNANCE_CACHE_VERSION = bumped_version + _set_ttl_cached_version(APP_GOVERNANCE_SHARED_VERSION_CACHE, bumped_version) + return bumped_version + except Exception as ex: + _logger.warning("[ASC] Governance cache version bump failed; using local version fallback: %s", ex) + if callable(log_event_func): + log_event_func( + "[ASC] Governance cache version bump failed; using local version fallback.", + extra={'version_doc_id': GOVERNANCE_CACHE_VERSION_DOC_ID, 'error': str(ex)}, + level=logging.WARNING, + ) + + with _app_cache_lock: + APP_GOVERNANCE_CACHE_VERSION += 1 + fallback_version = APP_GOVERNANCE_CACHE_VERSION + _set_ttl_cached_version(APP_GOVERNANCE_SHARED_VERSION_CACHE, fallback_version) + return fallback_version + + +def _get_stream_session_metadata_key(cache_key): + return f'STREAM_SESSION_META:{cache_key}' + + +def _get_stream_session_events_key(cache_key): + return f'STREAM_SESSION_EVENTS:{cache_key}' + + +def _initialize_stream_session_cache_fallback(cache_key, metadata, ttl_seconds=None, log_event_func=None): + expiration_timestamp = _get_expiration_timestamp(ttl_seconds) + with _app_cache_lock: + APP_STREAM_SESSION_METADATA[cache_key] = { + 'value': copy.deepcopy(metadata or {}), + 'expires_at': expiration_timestamp, + } + APP_STREAM_SESSION_EVENTS[cache_key] = { + 'value': [], + 'expires_at': expiration_timestamp, + } + _set_cosmos_cache_entry( + _get_stream_session_metadata_key(cache_key), + metadata or {}, + ttl_seconds=ttl_seconds, + log_event_func=log_event_func, + ) + _set_cosmos_cache_entry( + _get_stream_session_events_key(cache_key), + [], + ttl_seconds=ttl_seconds, + log_event_func=log_event_func, + ) + + +def _set_stream_session_meta_fallback(cache_key, metadata, ttl_seconds=None, log_event_func=None): + expiration_timestamp = _get_expiration_timestamp(ttl_seconds) + with _app_cache_lock: + APP_STREAM_SESSION_METADATA[cache_key] = { + 'value': copy.deepcopy(metadata or {}), + 'expires_at': expiration_timestamp, + } + _set_cosmos_cache_entry( + _get_stream_session_metadata_key(cache_key), + metadata or {}, + ttl_seconds=ttl_seconds, + log_event_func=log_event_func, + ) + + +def _get_stream_session_meta_fallback(cache_key, log_event_func=None): + with _app_cache_lock: + entry = APP_STREAM_SESSION_METADATA.get(cache_key) + if entry and not _is_expired(entry): + return copy.deepcopy(entry.get('value') or {}) + if entry: + APP_STREAM_SESSION_METADATA.pop(cache_key, None) + + cached = _get_cosmos_cache_entry( + _get_stream_session_metadata_key(cache_key), + log_event_func=log_event_func, + ) + return copy.deepcopy(cached or {}) if cached is not None else None + + +def _append_stream_session_event_fallback(cache_key, event_text, ttl_seconds=None, log_event_func=None): + expiration_timestamp = _get_expiration_timestamp(ttl_seconds) + with _app_cache_lock: + entry = APP_STREAM_SESSION_EVENTS.get(cache_key) + if _is_expired(entry): + entry = {'value': [], 'expires_at': expiration_timestamp} + APP_STREAM_SESSION_EVENTS[cache_key] = entry + entry['value'].append(event_text) + if expiration_timestamp is not None: + entry['expires_at'] = expiration_timestamp + events_payload = list(entry.get('value') or []) + _set_cosmos_cache_entry( + _get_stream_session_events_key(cache_key), + events_payload, + ttl_seconds=ttl_seconds, + log_event_func=log_event_func, + ) + + +def _get_stream_session_events_fallback(cache_key, start_index=0, log_event_func=None): + start = int(start_index or 0) + with _app_cache_lock: + entry = APP_STREAM_SESSION_EVENTS.get(cache_key) + if entry and not _is_expired(entry): + return list((entry.get('value') or [])[start:]) + if entry: + APP_STREAM_SESSION_EVENTS.pop(cache_key, None) + + cached = _get_cosmos_cache_entry( + _get_stream_session_events_key(cache_key), + log_event_func=log_event_func, + ) + return list((cached or [])[start:]) + + +def _delete_stream_session_cache_fallback(cache_key, log_event_func=None): + with _app_cache_lock: + APP_STREAM_SESSION_METADATA.pop(cache_key, None) + APP_STREAM_SESSION_EVENTS.pop(cache_key, None) + _delete_cosmos_cache_entry( + _get_stream_session_metadata_key(cache_key), + log_event_func=log_event_func, + ) + _delete_cosmos_cache_entry( + _get_stream_session_events_key(cache_key), + log_event_func=log_event_func, + ) + + +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_fallback(user_id, log_event_func=None): + with _app_cache_lock: + entry = APP_USER_UI_SETTINGS_CACHE.get(user_id) + if entry and not _is_expired(entry): + return copy.deepcopy(entry.get('value') or {}) + if entry: + APP_USER_UI_SETTINGS_CACHE.pop(user_id, None) + + cached = _get_cosmos_cache_entry( + _get_user_ui_settings_cache_key(user_id), + log_event_func=log_event_func, + ) + return copy.deepcopy(cached) if cached is not None else None + + +def _set_user_ui_settings_cache_fallback(user_id, ui_settings, ttl_seconds=None, log_event_func=None): + ttl = int(ttl_seconds or USER_UI_SETTINGS_CACHE_TTL_SECONDS) + expiration_timestamp = _get_expiration_timestamp(ttl) + with _app_cache_lock: + APP_USER_UI_SETTINGS_CACHE[user_id] = { + 'value': copy.deepcopy(ui_settings or {}), + 'expires_at': expiration_timestamp, + } + _set_cosmos_cache_entry( + _get_user_ui_settings_cache_key(user_id), + ui_settings or {}, + ttl_seconds=ttl, + log_event_func=log_event_func, + ) + + +def _delete_user_ui_settings_cache_fallback(user_id, log_event_func=None): + with _app_cache_lock: + APP_USER_UI_SETTINGS_CACHE.pop(user_id, None) + _delete_cosmos_cache_entry( + _get_user_ui_settings_cache_key(user_id), + log_event_func=log_event_func, + ) + + +def _assign_fallback_cache_functions(log_event_func=None): + global update_settings_cache, get_settings_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 + global APP_REDIS_CLIENT + + app_cache_is_using_redis = False + APP_REDIS_CLIENT = None + update_settings_cache = lambda new_settings: _update_settings_cache_fallback( + new_settings, + log_event_func=log_event_func, + ) + get_settings_cache = lambda: _get_settings_cache_fallback(log_event_func=log_event_func) + get_app_settings_cache_version = lambda: _get_app_settings_cache_version_fallback( + log_event_func=log_event_func, + ) + bump_app_settings_cache_version = lambda: _bump_app_settings_cache_version_fallback( + log_event_func=log_event_func, + ) + initialize_stream_session_cache = lambda cache_key, metadata, ttl_seconds=None: ( + _initialize_stream_session_cache_fallback( + cache_key, + metadata, + ttl_seconds=ttl_seconds, + log_event_func=log_event_func, + ) + ) + set_stream_session_meta = lambda cache_key, metadata, ttl_seconds=None: ( + _set_stream_session_meta_fallback( + cache_key, + metadata, + ttl_seconds=ttl_seconds, + log_event_func=log_event_func, + ) + ) + get_stream_session_meta = lambda cache_key: _get_stream_session_meta_fallback( + cache_key, + log_event_func=log_event_func, + ) + append_stream_session_event = lambda cache_key, event_text, ttl_seconds=None: ( + _append_stream_session_event_fallback( + cache_key, + event_text, + ttl_seconds=ttl_seconds, + log_event_func=log_event_func, + ) + ) + get_stream_session_events = lambda cache_key, start_index=0: _get_stream_session_events_fallback( + cache_key, + start_index=start_index, + log_event_func=log_event_func, + ) + delete_stream_session_cache = lambda cache_key: _delete_stream_session_cache_fallback( + cache_key, + log_event_func=log_event_func, + ) + get_user_ui_settings_cache = lambda user_id: _get_user_ui_settings_cache_fallback( + user_id, + log_event_func=log_event_func, + ) + set_user_ui_settings_cache = lambda user_id, ui_settings, ttl_seconds=None: ( + _set_user_ui_settings_cache_fallback( + user_id, + ui_settings, + ttl_seconds=ttl_seconds, + log_event_func=log_event_func, + ) + ) + delete_user_ui_settings_cache = lambda user_id: _delete_user_ui_settings_cache_fallback( + user_id, + log_event_func=log_event_func, + ) + get_governance_cache_version = lambda: _get_governance_cache_version_fallback( + log_event_func=log_event_func, + ) + bump_governance_cache_version = lambda: _bump_governance_cache_version_fallback( + log_event_func=log_event_func, + ) + + +def get_app_cache_redis_client(): + """Return the configured Redis client for shared cache consumers, when available.""" + return APP_REDIS_CLIENT if app_cache_is_using_redis else None + + def configure_app_cache(settings, redis_cache_endpoint=None): global _settings, update_settings_cache, get_settings_cache, APP_SETTINGS_CACHE global APP_USER_UI_SETTINGS_CACHE, APP_STREAM_SESSION_METADATA, APP_STREAM_SESSION_EVENTS @@ -199,65 +691,79 @@ def configure_app_cache(settings, redis_cache_endpoint=None): 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 + global APP_REDIS_CLIENT # Local import to avoid circular dependency: functions_keyvault imports app_settings_cache. from functions_appinsights import log_event _settings = settings use_redis = _settings.get('enable_redis_cache', False) app_cache_is_using_redis = False + APP_REDIS_CLIENT = None if use_redis: - app_cache_is_using_redis = True redis_url = settings.get('redis_url', '').strip() redis_auth_type = settings.get('redis_auth_type', 'key').strip().lower() - if redis_auth_type == 'managed_identity': - log_event("[ASC] Redis enabled using Managed Identity", level=logging.INFO) - redis_client = create_redis_managed_identity_client( - redis_url, - settings=settings - ) - elif redis_auth_type == 'key_vault': - log_event("[ASC] Redis enabled using Key Vault Secret", level=logging.INFO) - # Local import to avoid circular dependency: functions_keyvault imports app_settings_cache. - from functions_keyvault import retrieve_secret_direct - redis_key_secret_name = settings.get('redis_key', '').strip() - try: + try: + if not redis_url: + raise ValueError('Redis cache is enabled but redis_url is empty.') + if redis_auth_type == 'managed_identity': + log_event("[ASC] Redis enabled using Managed Identity", level=logging.INFO) + redis_client = create_redis_managed_identity_client( + redis_url, + settings=settings + ) + elif redis_auth_type == 'key_vault': + log_event("[ASC] Redis enabled using Key Vault Secret", level=logging.INFO) + # Local import to avoid circular dependency: functions_keyvault imports app_settings_cache. + from functions_keyvault import retrieve_secret_direct + redis_key_secret_name = settings.get('redis_key', '').strip() # Pass settings directly: get_settings_cache() is still None at this point # because configure_app_cache has not finished initialising the cache yet. redis_password = retrieve_secret_direct(redis_key_secret_name, settings=settings) if redis_password: redis_password = redis_password.strip() log_event("[ASC] Redis key retrieved from Key Vault successfully", level=logging.INFO) - except Exception as kv_err: - log_event(f"[ASC] ERROR: Failed to retrieve Redis key from Key Vault: {kv_err}", level=logging.ERROR, exceptionTraceback=True) - raise - - redis_client = Redis( - host=redis_url, - port=6380, - db=0, - password=redis_password, - ssl=True - ) - else: - redis_key = settings.get('redis_key', '').strip() - log_event("[ASC] Redis enabled using Access Key", level=logging.INFO) - redis_client = Redis( - host=redis_url, - port=6380, - db=0, - password=redis_key, - ssl=True - ) + + redis_client = Redis( + host=redis_url, + port=6380, + db=0, + password=redis_password, + ssl=True + ) + else: + redis_key = settings.get('redis_key', '').strip() + log_event("[ASC] Redis enabled using Access Key", level=logging.INFO) + redis_client = Redis( + host=redis_url, + port=6380, + db=0, + password=redis_key, + ssl=True + ) + app_cache_is_using_redis = True + APP_REDIS_CLIENT = redis_client + except Exception as redis_init_error: + _log_cache_fallback('redis_initialization', redis_init_error, log_event_func=log_event) + _assign_fallback_cache_functions(log_event_func=log_event) + return def get_app_settings_cache_version_redis(): - cached = redis_client.get(APP_SETTINGS_CACHE_VERSION_KEY) - if cached is None: - redis_client.setnx(APP_SETTINGS_CACHE_VERSION_KEY, 0) - return 0 - return _normalize_cache_version(cached) + try: + cached = redis_client.get(APP_SETTINGS_CACHE_VERSION_KEY) + if cached is None: + redis_client.setnx(APP_SETTINGS_CACHE_VERSION_KEY, 0) + return 0 + return _normalize_cache_version(cached) + except Exception as ex: + _log_cache_fallback('get_app_settings_cache_version', ex, log_event_func=log_event) + return _get_app_settings_cache_version_fallback(log_event_func=log_event) def bump_app_settings_cache_version_redis(): - return _normalize_cache_version(redis_client.incr(APP_SETTINGS_CACHE_VERSION_KEY)) + try: + return _normalize_cache_version(redis_client.incr(APP_SETTINGS_CACHE_VERSION_KEY)) + except Exception as ex: + _log_cache_fallback('bump_app_settings_cache_version', ex, log_event_func=log_event) + return _bump_app_settings_cache_version_fallback(log_event_func=log_event) def get_ttl_cached_app_settings_version_redis(): now = time.time() @@ -271,26 +777,36 @@ def get_ttl_cached_app_settings_version_redis(): def update_settings_cache_redis(new_settings): global APP_SETTINGS_CACHE, APP_SETTINGS_CACHE_VERSION - redis_client.set(APP_SETTINGS_CACHE_KEY, json.dumps(new_settings)) - shared_version = get_app_settings_cache_version_redis() - with _app_cache_lock: - APP_SETTINGS_CACHE = new_settings - APP_SETTINGS_CACHE_VERSION = shared_version - _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, shared_version) + try: + redis_client.set(APP_SETTINGS_CACHE_KEY, json.dumps(new_settings)) + shared_version = get_app_settings_cache_version_redis() + with _app_cache_lock: + APP_SETTINGS_CACHE = copy.deepcopy(new_settings or {}) + APP_SETTINGS_CACHE_VERSION = shared_version + _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, shared_version) + except Exception as ex: + _log_cache_fallback('update_settings_cache', ex, log_event_func=log_event) + _update_settings_cache_fallback(new_settings, log_event_func=log_event) def get_settings_cache_redis(): global APP_SETTINGS_CACHE, APP_SETTINGS_CACHE_VERSION - shared_version = get_ttl_cached_app_settings_version_redis() - with _app_cache_lock: - if APP_SETTINGS_CACHE and APP_SETTINGS_CACHE_VERSION == shared_version: - return APP_SETTINGS_CACHE + try: + shared_version = get_ttl_cached_app_settings_version_redis() + with _app_cache_lock: + if APP_SETTINGS_CACHE and APP_SETTINGS_CACHE_VERSION == shared_version: + return copy.deepcopy(APP_SETTINGS_CACHE) - cached = redis_client.get(APP_SETTINGS_CACHE_KEY) - loaded_settings = json.loads(cached) if cached else {} - with _app_cache_lock: - APP_SETTINGS_CACHE = loaded_settings - APP_SETTINGS_CACHE_VERSION = shared_version - return loaded_settings + cached = redis_client.get(APP_SETTINGS_CACHE_KEY) + if cached is None: + return _get_settings_cache_fallback(log_event_func=log_event) + loaded_settings = json.loads(cached) + with _app_cache_lock: + APP_SETTINGS_CACHE = copy.deepcopy(loaded_settings or {}) + APP_SETTINGS_CACHE_VERSION = shared_version + return copy.deepcopy(loaded_settings or {}) + except Exception as ex: + _log_cache_fallback('get_settings_cache', ex, log_event_func=log_event) + return _get_settings_cache_fallback(log_event_func=log_event) def get_stream_session_metadata_key(cache_key): return f'STREAM_SESSION_META:{cache_key}' @@ -299,88 +815,156 @@ def get_stream_session_events_key(cache_key): return f'STREAM_SESSION_EVENTS:{cache_key}' def initialize_stream_session_cache_redis(cache_key, metadata, ttl_seconds=None): - metadata_key = get_stream_session_metadata_key(cache_key) - events_key = get_stream_session_events_key(cache_key) - pipeline = redis_client.pipeline() - pipeline.delete(events_key) - pipeline.set(metadata_key, json.dumps(metadata)) - if ttl_seconds is not None: - pipeline.expire(metadata_key, int(ttl_seconds)) - pipeline.execute() + try: + metadata_key = get_stream_session_metadata_key(cache_key) + events_key = get_stream_session_events_key(cache_key) + pipeline = redis_client.pipeline() + pipeline.delete(events_key) + pipeline.set(metadata_key, json.dumps(metadata)) + if ttl_seconds is not None: + pipeline.expire(metadata_key, int(ttl_seconds)) + pipeline.execute() + except Exception as ex: + _log_cache_fallback('initialize_stream_session_cache', ex, log_event_func=log_event) + _initialize_stream_session_cache_fallback( + cache_key, + metadata, + ttl_seconds=ttl_seconds, + log_event_func=log_event, + ) def set_stream_session_meta_redis(cache_key, metadata, ttl_seconds=None): - metadata_key = get_stream_session_metadata_key(cache_key) - events_key = get_stream_session_events_key(cache_key) - pipeline = redis_client.pipeline() - pipeline.set(metadata_key, json.dumps(metadata)) - if ttl_seconds is not None: - pipeline.expire(metadata_key, int(ttl_seconds)) - if redis_client.exists(events_key): - pipeline.expire(events_key, int(ttl_seconds)) - pipeline.execute() + try: + metadata_key = get_stream_session_metadata_key(cache_key) + events_key = get_stream_session_events_key(cache_key) + pipeline = redis_client.pipeline() + pipeline.set(metadata_key, json.dumps(metadata)) + if ttl_seconds is not None: + pipeline.expire(metadata_key, int(ttl_seconds)) + if redis_client.exists(events_key): + pipeline.expire(events_key, int(ttl_seconds)) + pipeline.execute() + except Exception as ex: + _log_cache_fallback('set_stream_session_meta', ex, log_event_func=log_event) + _set_stream_session_meta_fallback( + cache_key, + metadata, + ttl_seconds=ttl_seconds, + log_event_func=log_event, + ) def get_stream_session_meta_redis(cache_key): - cached = redis_client.get(get_stream_session_metadata_key(cache_key)) - return json.loads(cached) if cached else None + try: + cached = redis_client.get(get_stream_session_metadata_key(cache_key)) + return json.loads(cached) if cached else None + except Exception as ex: + _log_cache_fallback('get_stream_session_meta', ex, log_event_func=log_event) + return _get_stream_session_meta_fallback(cache_key, log_event_func=log_event) def append_stream_session_event_redis(cache_key, event_text, ttl_seconds=None): - metadata_key = get_stream_session_metadata_key(cache_key) - events_key = get_stream_session_events_key(cache_key) - pipeline = redis_client.pipeline() - pipeline.rpush(events_key, event_text) - if ttl_seconds is not None: - pipeline.expire(events_key, int(ttl_seconds)) - if redis_client.exists(metadata_key): - pipeline.expire(metadata_key, int(ttl_seconds)) - pipeline.execute() + try: + metadata_key = get_stream_session_metadata_key(cache_key) + events_key = get_stream_session_events_key(cache_key) + pipeline = redis_client.pipeline() + pipeline.rpush(events_key, event_text) + if ttl_seconds is not None: + pipeline.expire(events_key, int(ttl_seconds)) + if redis_client.exists(metadata_key): + pipeline.expire(metadata_key, int(ttl_seconds)) + pipeline.execute() + except Exception as ex: + _log_cache_fallback('append_stream_session_event', ex, log_event_func=log_event) + _append_stream_session_event_fallback( + cache_key, + event_text, + ttl_seconds=ttl_seconds, + log_event_func=log_event, + ) def get_stream_session_events_redis(cache_key, start_index=0): - cached_events = redis_client.lrange( - get_stream_session_events_key(cache_key), - int(start_index or 0), - -1, - ) - normalized_events = [] - for event in cached_events: - if isinstance(event, bytes): - normalized_events.append(event.decode('utf-8')) - else: - normalized_events.append(event) - return normalized_events + try: + cached_events = redis_client.lrange( + get_stream_session_events_key(cache_key), + int(start_index or 0), + -1, + ) + normalized_events = [] + for event in cached_events: + if isinstance(event, bytes): + normalized_events.append(event.decode('utf-8')) + else: + normalized_events.append(event) + return normalized_events + except Exception as ex: + _log_cache_fallback('get_stream_session_events', ex, log_event_func=log_event) + return _get_stream_session_events_fallback( + cache_key, + start_index=start_index, + log_event_func=log_event, + ) def delete_stream_session_cache_redis(cache_key): - redis_client.delete( - get_stream_session_metadata_key(cache_key), - get_stream_session_events_key(cache_key), - ) + try: + redis_client.delete( + get_stream_session_metadata_key(cache_key), + get_stream_session_events_key(cache_key), + ) + except Exception as ex: + _log_cache_fallback('delete_stream_session_cache', ex, log_event_func=log_event) + _delete_stream_session_cache_fallback(cache_key, log_event_func=log_event) 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 + try: + cached = redis_client.get(get_user_ui_settings_cache_key(user_id)) + return json.loads(cached) if cached else None + except Exception as ex: + _log_cache_fallback('get_user_ui_settings_cache', ex, log_event_func=log_event) + return _get_user_ui_settings_cache_fallback(user_id, log_event_func=log_event) 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 {}) - ) + try: + redis_client.setex( + get_user_ui_settings_cache_key(user_id), + ttl, + json.dumps(ui_settings or {}) + ) + except Exception as ex: + _log_cache_fallback('set_user_ui_settings_cache', ex, log_event_func=log_event) + _set_user_ui_settings_cache_fallback( + user_id, + ui_settings, + ttl_seconds=ttl, + log_event_func=log_event, + ) def delete_user_ui_settings_cache_redis(user_id): - redis_client.delete(get_user_ui_settings_cache_key(user_id)) + try: + redis_client.delete(get_user_ui_settings_cache_key(user_id)) + except Exception as ex: + _log_cache_fallback('delete_user_ui_settings_cache', ex, log_event_func=log_event) + _delete_user_ui_settings_cache_fallback(user_id, log_event_func=log_event) def get_governance_cache_version_redis(): - cached = redis_client.get(GOVERNANCE_CACHE_VERSION_KEY) - if cached is None: - redis_client.setnx(GOVERNANCE_CACHE_VERSION_KEY, 0) - return 0 - return _normalize_cache_version(cached) + try: + cached = redis_client.get(GOVERNANCE_CACHE_VERSION_KEY) + if cached is None: + redis_client.setnx(GOVERNANCE_CACHE_VERSION_KEY, 0) + return 0 + return _normalize_cache_version(cached) + except Exception as ex: + _log_cache_fallback('get_governance_cache_version', ex, log_event_func=log_event) + return _get_governance_cache_version_fallback(log_event_func=log_event) def bump_governance_cache_version_redis(): - return _normalize_cache_version(redis_client.incr(GOVERNANCE_CACHE_VERSION_KEY)) + try: + return _normalize_cache_version(redis_client.incr(GOVERNANCE_CACHE_VERSION_KEY)) + except Exception as ex: + _log_cache_fallback('bump_governance_cache_version', ex, log_event_func=log_event) + return _bump_governance_cache_version_fallback(log_event_func=log_event) update_settings_cache = update_settings_cache_redis get_settings_cache = get_settings_cache_redis @@ -528,7 +1112,13 @@ def get_app_settings_cache_version_mem(): APP_SETTINGS_CACHE_VERSION, log_event_func=log_event, ) - except Exception: + except Exception as ex: + _logger.warning("[ASC] Shared cache version read failed; using local version fallback: %s", ex) + log_event( + "[ASC] Shared cache version read failed; using local version fallback.", + extra={'version_doc_id': APP_SETTINGS_CACHE_VERSION_DOC_ID, 'error': str(ex)}, + level=logging.WARNING, + ) with _app_cache_lock: return APP_SETTINGS_CACHE_VERSION @@ -546,8 +1136,13 @@ def bump_app_settings_cache_version_mem(): APP_SETTINGS_CACHE_VERSION = bumped_version _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, bumped_version) return bumped_version - except Exception: - pass + except Exception as ex: + _logger.warning("[ASC] Shared cache version bump failed; using local version fallback: %s", ex) + log_event( + "[ASC] Shared cache version bump failed; using local version fallback.", + extra={'version_doc_id': APP_SETTINGS_CACHE_VERSION_DOC_ID, 'error': str(ex)}, + level=logging.WARNING, + ) with _app_cache_lock: APP_SETTINGS_CACHE_VERSION += 1 @@ -566,7 +1161,13 @@ def get_governance_cache_version_mem(): APP_GOVERNANCE_CACHE_VERSION, log_event_func=log_event, ) - except Exception: + except Exception as ex: + _logger.warning("[ASC] Governance cache version read failed; using local version fallback: %s", ex) + log_event( + "[ASC] Governance cache version read failed; using local version fallback.", + extra={'version_doc_id': GOVERNANCE_CACHE_VERSION_DOC_ID, 'error': str(ex)}, + level=logging.WARNING, + ) with _app_cache_lock: return APP_GOVERNANCE_CACHE_VERSION @@ -584,8 +1185,13 @@ def bump_governance_cache_version_mem(): APP_GOVERNANCE_CACHE_VERSION = bumped_version _set_ttl_cached_version(APP_GOVERNANCE_SHARED_VERSION_CACHE, bumped_version) return bumped_version - except Exception: - pass + except Exception as ex: + _logger.warning("[ASC] Governance cache version bump failed; using local version fallback: %s", ex) + log_event( + "[ASC] Governance cache version bump failed; using local version fallback.", + extra={'version_doc_id': GOVERNANCE_CACHE_VERSION_DOC_ID, 'error': str(ex)}, + level=logging.WARNING, + ) with _app_cache_lock: APP_GOVERNANCE_CACHE_VERSION += 1 diff --git a/application/single_app/background_tasks.py b/application/single_app/background_tasks.py index 109b4a43..464255dd 100644 --- a/application/single_app/background_tasks.py +++ b/application/single_app/background_tasks.py @@ -25,6 +25,12 @@ calculate_cosmos_throughput_autoscale_interval_seconds, evaluate_and_apply_cosmos_throughput_scaling, ) +from functions_app_maintenance import ( + APP_MAINTENANCE_LOCK_NAME, + calculate_app_maintenance_sleep_seconds, + get_app_maintenance_settings, + run_app_maintenance_once, +) from functions_debug import debug_print from functions_data_management import check_due_data_management_jobs_once from functions_file_sync import check_due_file_sync_sources_once @@ -696,6 +702,40 @@ def run_data_management_scheduler_loop(): time.sleep(60) +def run_app_maintenance_loop(): + """Run idempotent app maintenance tasks under a distributed lock.""" + while True: + lock_document = None + sleep_seconds = 3600 + try: + settings = get_settings() + maintenance_settings = get_app_maintenance_settings(settings) + sleep_seconds = maintenance_settings.get('check_interval_seconds', 3600) + if maintenance_settings.get('enabled') and maintenance_settings.get('run_on_startup'): + lock_document = acquire_distributed_task_lock( + APP_MAINTENANCE_LOCK_NAME, + lease_seconds=maintenance_settings.get('lease_seconds', 300), + ) + if lock_document: + maintenance_result = run_app_maintenance_once(triggered_by='background', settings=settings) + sleep_seconds = calculate_app_maintenance_sleep_seconds( + maintenance_result, + maintenance_settings, + ) + except Exception as exc: + log_event( + '[AppMaintenance] Error in maintenance scheduler loop.', + extra={'error': str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + finally: + if lock_document: + release_distributed_task_lock(lock_document) + + time.sleep(max(int(sleep_seconds or 3600), 15)) + + def start_background_task_threads(): """Start all background task loops for the current process.""" task_specs = [ @@ -708,6 +748,7 @@ def start_background_task_threads(): ('File Sync scheduler background task started.', run_file_sync_scheduler_loop), ('Tabular generated-output scheduler background task started.', run_tabular_generated_output_scheduler_loop), ('Data Management scheduler background task started.', run_data_management_scheduler_loop), + ('App maintenance background task started.', run_app_maintenance_loop), ] started_threads = [] diff --git a/application/single_app/config.py b/application/single_app/config.py index a22a9d0b..11f93c24 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -71,7 +71,8 @@ from io import BytesIO from typing import List -from azure.cosmos import CosmosClient, PartitionKey, exceptions +import azure.cosmos as azure_cosmos +from azure.cosmos import PartitionKey, exceptions from azure.cosmos.exceptions import CosmosResourceNotFoundError from azure.core.credentials import AzureKeyCredential from azure.ai.documentintelligence import DocumentIntelligenceClient @@ -95,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.015" +VERSION = "0.250.047" SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() != 'false' @@ -419,9 +420,9 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: cosmos_authentication_type = os.getenv("AZURE_COSMOS_AUTHENTICATION_TYPE", "key") #key or managed_identity if cosmos_authentication_type == "managed_identity": - cosmos_client = CosmosClient(cosmos_endpoint, credential=DefaultAzureCredential(), consistency_level="Session") + cosmos_client = azure_cosmos.CosmosClient(cosmos_endpoint, credential=DefaultAzureCredential(), consistency_level="Session") else: - cosmos_client = CosmosClient(cosmos_endpoint, cosmos_key, consistency_level="Session") + cosmos_client = azure_cosmos.CosmosClient(cosmos_endpoint, cosmos_key, consistency_level="Session") cosmos_database_name = "SimpleChat" cosmos_database = cosmos_client.create_database_if_not_exists(cosmos_database_name) @@ -564,6 +565,12 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/id") ) +cosmos_document_access_index_container_name = "document_access_index" +cosmos_document_access_index_container = cosmos_database.create_container_if_not_exists( + id=cosmos_document_access_index_container_name, + partition_key=PartitionKey(path="/scope_key") +) + cosmos_personal_file_sync_sources_container_name = "personal_file_sync_sources" cosmos_personal_file_sync_sources_container = cosmos_database.create_container_if_not_exists( id=cosmos_personal_file_sync_sources_container_name, diff --git a/application/single_app/functions_app_maintenance.py b/application/single_app/functions_app_maintenance.py new file mode 100644 index 00000000..fa1cb398 --- /dev/null +++ b/application/single_app/functions_app_maintenance.py @@ -0,0 +1,486 @@ +# functions_app_maintenance.py +"""Application maintenance framework for safe Cosmos-backed foundation tasks.""" + +import logging +import time +import uuid +from datetime import datetime, timezone + +from config import VERSION, cosmos_governance_policies_container, cosmos_settings_container +from functions_appinsights import log_event +from functions_cosmos_indexing import ( + COSMOS_INDEXING_POLICY_APPLY_SETTING, + get_cosmos_indexing_policy_status, + run_cosmos_indexing_policy_maintenance, +) +from functions_conversation_cache import get_conversation_cache_metrics, get_conversation_cache_settings +from functions_document_access_index import ( + DOCUMENT_ACCESS_ACTIVE_MAINTENANCE_INTERVAL_SETTING, + DOCUMENT_ACCESS_BACKFILL_BATCH_SIZE_SETTING, + DOCUMENT_ACCESS_BACKFILL_ENABLED_SETTING, + DOCUMENT_ACCESS_REPAIR_BATCH_SIZE_SETTING, + get_document_access_index_settings, + get_document_access_index_backfill_status, + is_document_access_index_maintenance_pending, + refresh_document_access_cache_version_marker_ttls, + run_document_access_index_backfill_maintenance, +) +from functions_cosmos_stale_cleanup import ( + COSMOS_STALE_CACHE_CLEANUP_APPLY_SETTING, + COSMOS_STALE_CACHE_CLEANUP_BATCH_SIZE_SETTING, + COSMOS_STALE_CACHE_CLEANUP_MAX_BATCH_SIZE, + COSMOS_STALE_CACHE_CLEANUP_RUN_SETTING, + build_skipped_stale_cache_cleanup_result, + build_stale_cache_cleanup_status, + run_stale_cache_document_cleanup, +) +from functions_shared_cache import ensure_shared_cache_version_doc, get_shared_cache_metrics, get_shared_cache_version + + +APP_MAINTENANCE_STATE_DOC_ID = 'app_maintenance_state' +APP_MAINTENANCE_DOC_TYPE = 'app_maintenance_state' +APP_MAINTENANCE_LOCK_NAME = 'app_maintenance' +APP_MAINTENANCE_DEFAULT_INTERVAL_SECONDS = 3600 +APP_MAINTENANCE_DEFAULT_LEASE_SECONDS = 300 +APP_MAINTENANCE_DEFAULT_ACTIVE_INTERVAL_SECONDS = 30 + +CACHE_VERSION_DOCUMENTS = [ + { + 'id': 'app_settings_cache_version', + 'container': 'settings', + 'description': 'App settings cache invalidation version.', + }, + { + 'id': 'governance_cache_version', + 'container': 'governance_policies', + 'description': 'Governance and policy cache invalidation version.', + }, + { + 'id': 'custom_pages_cache_version', + 'container': 'settings', + 'description': 'Custom pages cache invalidation version.', + }, + { + 'id': 'chat_bootstrap_global_cache_version', + 'container': 'settings', + 'description': 'Chat bootstrap global cache invalidation version.', + }, + { + 'id': 'document_access_index_projection_version', + 'container': 'settings', + 'description': 'Document access index projection version for future read models.', + }, +] + + +def _utc_now(): + return datetime.now(timezone.utc) + + +def _utc_now_iso(): + return _utc_now().isoformat() + + +def _is_not_found_error(exc): + return getattr(exc, 'status_code', None) == 404 + + +def _get_cache_version_container(container_name): + if container_name == 'governance_policies': + return cosmos_governance_policies_container + return cosmos_settings_container + + +def _read_maintenance_state(): + try: + return cosmos_settings_container.read_item( + item=APP_MAINTENANCE_STATE_DOC_ID, + partition_key=APP_MAINTENANCE_STATE_DOC_ID, + ) + except Exception as ex: + if _is_not_found_error(ex): + return None + log_event( + '[AppMaintenance] Failed to read maintenance state.', + extra={'error': str(ex)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + raise + + +def _write_maintenance_state(state): + state_body = dict(state or {}) + state_body.update({ + 'id': APP_MAINTENANCE_STATE_DOC_ID, + 'type': APP_MAINTENANCE_DOC_TYPE, + 'updated_at': _utc_now_iso(), + }) + cosmos_settings_container.upsert_item(state_body) + return state_body + + +def _record_started(run_id, triggered_by, requested_by): + existing = _read_maintenance_state() or {} + state = dict(existing) + state.update({ + 'current_run_id': run_id, + 'last_status': 'running', + 'last_triggered_by': triggered_by, + 'last_requested_by': requested_by, + 'last_started_at': _utc_now_iso(), + 'last_completed_at': None, + 'last_duration_ms': None, + 'last_error': None, + 'app_version': VERSION, + }) + return _write_maintenance_state(state) + + +def _record_completed(run_id, started_at, triggered_by, requested_by, steps): + duration_ms = int((time.perf_counter() - started_at) * 1000) + existing = _read_maintenance_state() or {} + last_status = 'succeeded' + if any(step.get('status') == 'failed' for step in list(steps or [])): + last_status = 'succeeded_with_warnings' + state = dict(existing) + state.update({ + 'current_run_id': None, + 'last_run_id': run_id, + 'last_status': last_status, + 'last_triggered_by': triggered_by, + 'last_requested_by': requested_by, + 'last_completed_at': _utc_now_iso(), + 'last_duration_ms': duration_ms, + 'last_error': None, + 'last_steps': steps, + 'app_version': VERSION, + }) + return _write_maintenance_state(state) + + +def _record_failed(run_id, started_at, triggered_by, requested_by, error): + duration_ms = int((time.perf_counter() - started_at) * 1000) + existing = _read_maintenance_state() or {} + state = dict(existing) + state.update({ + 'current_run_id': None, + 'last_run_id': run_id, + 'last_status': 'failed', + 'last_triggered_by': triggered_by, + 'last_requested_by': requested_by, + 'last_completed_at': _utc_now_iso(), + 'last_duration_ms': duration_ms, + 'last_error': str(error), + 'app_version': VERSION, + }) + return _write_maintenance_state(state) + + +def get_app_maintenance_settings(settings): + """Normalize maintenance settings from app settings.""" + settings = settings or {} + document_access_settings = get_document_access_index_settings(settings) + enabled = bool(settings.get('enable_app_maintenance', True)) + run_on_startup = bool(settings.get('enable_startup_app_maintenance', True)) + document_access_container_enabled = bool(document_access_settings.get('container_enabled')) + return { + 'enabled': enabled, + 'run_on_startup': run_on_startup, + 'check_interval_seconds': max( + int(settings.get( + 'app_maintenance_check_interval_seconds', + APP_MAINTENANCE_DEFAULT_INTERVAL_SECONDS, + ) or APP_MAINTENANCE_DEFAULT_INTERVAL_SECONDS), + 60, + ), + 'lease_seconds': max( + int(settings.get( + 'app_maintenance_job_lease_seconds', + APP_MAINTENANCE_DEFAULT_LEASE_SECONDS, + ) or APP_MAINTENANCE_DEFAULT_LEASE_SECONDS), + 60, + ), + 'apply_cosmos_indexing_policies': bool(settings.get(COSMOS_INDEXING_POLICY_APPLY_SETTING, False)), + 'run_document_access_index_backfill': document_access_container_enabled, + 'document_access_index_auto_maintenance': ( + enabled and run_on_startup and document_access_container_enabled + ), + 'document_access_index_active_interval_seconds': max( + int(settings.get( + DOCUMENT_ACCESS_ACTIVE_MAINTENANCE_INTERVAL_SETTING, + APP_MAINTENANCE_DEFAULT_ACTIVE_INTERVAL_SECONDS, + ) or APP_MAINTENANCE_DEFAULT_ACTIVE_INTERVAL_SECONDS), + 15, + ), + 'document_access_index_backfill_batch_size': max( + int(settings.get(DOCUMENT_ACCESS_BACKFILL_BATCH_SIZE_SETTING, 200) or 200), + 1, + ), + 'document_access_index_repair_batch_size': max( + int(settings.get(DOCUMENT_ACCESS_REPAIR_BATCH_SIZE_SETTING, 100) or 100), + 1, + ), + 'run_stale_cache_cleanup': bool(settings.get(COSMOS_STALE_CACHE_CLEANUP_RUN_SETTING, False)), + 'apply_stale_cache_cleanup': bool(settings.get(COSMOS_STALE_CACHE_CLEANUP_APPLY_SETTING, False)), + 'stale_cache_cleanup_batch_size': min( + max( + int(settings.get(COSMOS_STALE_CACHE_CLEANUP_BATCH_SIZE_SETTING, 100) or 100), + 1, + ), + COSMOS_STALE_CACHE_CLEANUP_MAX_BATCH_SIZE, + ), + } + + +def calculate_app_maintenance_sleep_seconds(maintenance_result, maintenance_settings): + """Return the next scheduler sleep interval, using a short loop while DAI work remains.""" + maintenance_settings = maintenance_settings or {} + default_sleep_seconds = max( + int(maintenance_settings.get('check_interval_seconds') or APP_MAINTENANCE_DEFAULT_INTERVAL_SECONDS), + 60, + ) + active_sleep_seconds = max( + int( + maintenance_settings.get('document_access_index_active_interval_seconds') + or APP_MAINTENANCE_DEFAULT_ACTIVE_INTERVAL_SECONDS + ), + 15, + ) + if is_document_access_index_maintenance_pending(_extract_document_access_index_step_status(maintenance_result)): + return active_sleep_seconds + return default_sleep_seconds + + +def _extract_document_access_index_step_status(maintenance_result): + if not isinstance(maintenance_result, dict): + return None + for step in maintenance_result.get('steps') or []: + if isinstance(step, dict) and step.get('name') == 'document_access_index_backfill': + results = step.get('results') if isinstance(step.get('results'), dict) else {} + return results.get('current_status') or results + return maintenance_result.get('document_access_index_backfill') + + +def _extract_last_step_results(state, step_name): + for step in reversed(list((state or {}).get('last_steps') or [])): + if isinstance(step, dict) and step.get('name') == step_name: + results = step.get('results') + return results if isinstance(results, dict) else None + return None + + +def initialize_cache_version_documents(): + """Ensure shared cache version documents exist in the settings container.""" + results = [] + for version_doc in CACHE_VERSION_DOCUMENTS: + result = ensure_shared_cache_version_doc( + version_doc['id'], + initial_version=0, + description=version_doc.get('description', ''), + container=_get_cache_version_container(version_doc.get('container')), + ) + result['container'] = version_doc.get('container', 'settings') + results.append(result) + return results + + +def get_cache_version_document_status(): + """Return current cache version document status for admin and diagnostics.""" + statuses = [] + for version_doc in CACHE_VERSION_DOCUMENTS: + version = get_shared_cache_version( + version_doc['id'], + default_version=0, + container=_get_cache_version_container(version_doc.get('container')), + ) + statuses.append({ + 'id': version_doc['id'], + 'container': version_doc.get('container', 'settings'), + 'description': version_doc.get('description', ''), + 'version': version, + }) + return statuses + + +def get_app_maintenance_status(settings=None): + """Return the latest app maintenance state and foundation document status.""" + state = _read_maintenance_state() or { + 'id': APP_MAINTENANCE_STATE_DOC_ID, + 'type': APP_MAINTENANCE_DOC_TYPE, + 'last_status': 'not_run', + 'app_version': VERSION, + } + maintenance_settings = get_app_maintenance_settings(settings or {}) + document_access_index_status = get_document_access_index_backfill_status(settings=settings or {}) + if isinstance(document_access_index_status.get('maintenance'), dict): + document_access_index_status['maintenance'].update({ + 'auto_maintenance_enabled': maintenance_settings.get('document_access_index_auto_maintenance'), + 'active_interval_seconds': maintenance_settings.get('document_access_index_active_interval_seconds'), + 'check_interval_seconds': maintenance_settings.get('check_interval_seconds'), + }) + return { + 'success': True, + 'state': state, + 'cache_version_documents': get_cache_version_document_status(), + 'shared_cache_metrics': get_shared_cache_metrics(), + 'conversation_cache': { + 'settings': get_conversation_cache_settings(settings or {}), + 'metrics': get_conversation_cache_metrics(), + }, + 'cosmos_indexing_policies': get_cosmos_indexing_policy_status(), + 'stale_cache_cleanup': build_stale_cache_cleanup_status( + _extract_last_step_results(state, 'stale_cache_document_cleanup') + ), + 'document_access_cache_version_ttl_hygiene': ( + _extract_last_step_results(state, 'document_access_cache_version_ttl_hygiene') or {} + ), + 'document_access_index_backfill': document_access_index_status, + 'settings': { + 'apply_cosmos_indexing_policies': maintenance_settings.get('apply_cosmos_indexing_policies'), + 'cosmos_indexing_policy_apply_setting': COSMOS_INDEXING_POLICY_APPLY_SETTING, + 'run_stale_cache_cleanup': maintenance_settings.get('run_stale_cache_cleanup'), + 'apply_stale_cache_cleanup': maintenance_settings.get('apply_stale_cache_cleanup'), + 'stale_cache_cleanup_batch_size': maintenance_settings.get('stale_cache_cleanup_batch_size'), + 'stale_cache_cleanup_run_setting': COSMOS_STALE_CACHE_CLEANUP_RUN_SETTING, + 'stale_cache_cleanup_apply_setting': COSMOS_STALE_CACHE_CLEANUP_APPLY_SETTING, + 'run_document_access_index_backfill': maintenance_settings.get('run_document_access_index_backfill'), + 'document_access_index_auto_maintenance': maintenance_settings.get('document_access_index_auto_maintenance'), + 'document_access_index_active_interval_seconds': maintenance_settings.get('document_access_index_active_interval_seconds'), + 'document_access_index_backfill_setting': DOCUMENT_ACCESS_BACKFILL_ENABLED_SETTING, + }, + 'app_version': VERSION, + } + + +def run_app_maintenance_once( + triggered_by='manual', + requested_by=None, + settings=None, + apply_indexing_policies=None, + run_document_access_backfill=None, + reset_document_access_backfill=False, + run_stale_cache_cleanup=None, + apply_stale_cache_cleanup=None, +): + """Run idempotent app maintenance tasks once and persist the outcome.""" + run_id = str(uuid.uuid4()) + started_at = time.perf_counter() + try: + maintenance_settings = get_app_maintenance_settings(settings or {}) + if apply_indexing_policies is None: + apply_indexing_policies = maintenance_settings.get('apply_cosmos_indexing_policies', False) + if run_document_access_backfill is None: + run_document_access_backfill = maintenance_settings.get('run_document_access_index_backfill', False) + if run_stale_cache_cleanup is None: + run_stale_cache_cleanup = maintenance_settings.get('run_stale_cache_cleanup', False) + if apply_stale_cache_cleanup is None: + apply_stale_cache_cleanup = maintenance_settings.get('apply_stale_cache_cleanup', False) + _record_started(run_id, triggered_by, requested_by) + log_event( + '[AppMaintenance] Maintenance run started.', + extra={'run_id': run_id, 'triggered_by': triggered_by, 'requested_by': requested_by}, + level=logging.INFO, + ) + cache_version_results = initialize_cache_version_documents() + indexing_policy_results = run_cosmos_indexing_policy_maintenance( + apply_changes=bool(apply_indexing_policies), + ) + if run_stale_cache_cleanup: + stale_cache_cleanup_results = run_stale_cache_document_cleanup( + apply_changes=bool(apply_stale_cache_cleanup), + batch_size=maintenance_settings.get('stale_cache_cleanup_batch_size'), + ) + else: + stale_cache_cleanup_results = build_skipped_stale_cache_cleanup_result() + document_access_cache_version_ttl_results = refresh_document_access_cache_version_marker_ttls( + settings=settings, + ) + document_access_backfill_results = run_document_access_index_backfill_maintenance( + settings=settings, + run_backfill=bool(run_document_access_backfill), + reset=bool(reset_document_access_backfill), + batch_size=maintenance_settings.get('document_access_index_backfill_batch_size'), + repair_batch_size=maintenance_settings.get('document_access_index_repair_batch_size'), + ) + steps = [ + { + 'name': 'initialize_cache_version_documents', + 'status': 'succeeded', + 'results': cache_version_results, + }, + { + 'name': 'cosmos_indexing_policy_maintenance', + 'status': 'succeeded' if indexing_policy_results.get('success') else 'failed', + 'apply_requested': bool(apply_indexing_policies), + 'results': indexing_policy_results, + }, + { + 'name': 'stale_cache_document_cleanup', + 'status': 'succeeded' if stale_cache_cleanup_results.get('success') else 'failed', + 'run_requested': bool(run_stale_cache_cleanup), + 'apply_requested': bool(apply_stale_cache_cleanup), + 'results': stale_cache_cleanup_results, + }, + { + 'name': 'document_access_cache_version_ttl_hygiene', + 'status': 'succeeded' if document_access_cache_version_ttl_results.get('success') else 'failed', + 'results': document_access_cache_version_ttl_results, + }, + { + 'name': 'document_access_index_backfill', + 'status': 'succeeded' if document_access_backfill_results.get('success') else 'failed', + 'run_requested': bool(run_document_access_backfill), + 'reset_requested': bool(reset_document_access_backfill), + 'results': document_access_backfill_results, + }, + ] + overall_success = all(step.get('status') == 'succeeded' for step in steps) + state = _record_completed(run_id, started_at, triggered_by, requested_by, steps) + log_event( + '[AppMaintenance] Maintenance run completed.', + extra={ + 'run_id': run_id, + 'triggered_by': triggered_by, + 'duration_ms': state.get('last_duration_ms'), + 'success': overall_success, + }, + level=logging.INFO, + ) + return { + 'success': overall_success, + 'run_id': run_id, + 'state': state, + 'steps': steps, + 'error': None if overall_success else 'One or more maintenance steps failed.', + } + except Exception as ex: + try: + state = _record_failed(run_id, started_at, triggered_by, requested_by, ex) + except Exception as state_error: + state = { + 'last_status': 'failed', + 'last_error': 'Failed to record maintenance state.', + } + log_event( + '[AppMaintenance] Failed to record maintenance failure state.', + extra={ + 'run_id': run_id, + 'triggered_by': triggered_by, + 'error': str(state_error), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + log_event( + '[AppMaintenance] Maintenance run failed.', + extra={'run_id': run_id, 'triggered_by': triggered_by, 'error': str(ex)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return { + 'success': False, + 'run_id': run_id, + 'state': state, + 'error': str(ex), + } diff --git a/application/single_app/functions_chat_bootstrap_cache.py b/application/single_app/functions_chat_bootstrap_cache.py new file mode 100644 index 00000000..e852f0ba --- /dev/null +++ b/application/single_app/functions_chat_bootstrap_cache.py @@ -0,0 +1,221 @@ +# functions_chat_bootstrap_cache.py +"""Versioned chat bootstrap cache helpers for low-churn catalog payloads.""" + +import copy +import hashlib +import json +import logging +from typing import Any, Dict, Iterable, Optional + +import app_settings_cache +from functions_appinsights import log_event +from functions_shared_cache import ( + bump_shared_cache_version, + get_shared_cache_entry, + get_shared_cache_version, + set_shared_cache_entry, +) + + +CHAT_BOOTSTRAP_CACHE_NAMESPACE = "chat_bootstrap" +CHAT_BOOTSTRAP_GLOBAL_VERSION_DOC_ID = "chat_bootstrap_global_cache_version" +CHAT_BOOTSTRAP_USER_VERSION_DOC_PREFIX = "chat_bootstrap_user_cache_version:" +CHAT_BOOTSTRAP_DEFAULT_TTL_SECONDS = 300 + + +def _stable_hash(payload: Any) -> str: + serialized = json.dumps(payload, sort_keys=True, default=str, separators=(",", ":")) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _normalize_id_list(items: Iterable[Dict[str, Any]]) -> list[str]: + normalized = [] + for item in items or []: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "").strip() + if item_id: + normalized.append(item_id) + return sorted(set(normalized)) + + +def _get_redis_client(): + try: + return app_settings_cache.get_app_cache_redis_client() + except Exception as ex: + log_event( + "[ChatBootstrapCache] Redis client lookup failed; using Cosmos cache fallback.", + extra={"error": str(ex)}, + level=logging.WARNING, + ) + return None + + +def _get_app_settings_version() -> int: + getter = getattr(app_settings_cache, "get_app_settings_cache_version", None) + if not callable(getter): + return 0 + try: + return int(getter() or 0) + except Exception as ex: + log_event( + "[ChatBootstrapCache] App settings version lookup failed.", + extra={"error": str(ex)}, + level=logging.WARNING, + ) + return 0 + + +def _get_governance_version() -> int: + getter = getattr(app_settings_cache, "get_governance_cache_version", None) + if not callable(getter): + return 0 + try: + return int(getter() or 0) + except Exception as ex: + log_event( + "[ChatBootstrapCache] Governance cache version lookup failed.", + extra={"error": str(ex)}, + level=logging.WARNING, + ) + return 0 + + +def _get_user_version_doc_id(user_id: str) -> str: + normalized_user_id = str(user_id or "").strip() + if not normalized_user_id: + raise ValueError("user_id is required for chat bootstrap user cache versioning.") + return f"{CHAT_BOOTSTRAP_USER_VERSION_DOC_PREFIX}{normalized_user_id}" + + +def get_chat_bootstrap_versions(user_id: str) -> Dict[str, int]: + """Return shared versions that make chat bootstrap cache keys cross-worker safe.""" + return { + "global": get_shared_cache_version(CHAT_BOOTSTRAP_GLOBAL_VERSION_DOC_ID, default_version=0), + "user": get_shared_cache_version(_get_user_version_doc_id(user_id), default_version=0), + "settings": _get_app_settings_version(), + "governance": _get_governance_version(), + } + + +def build_chat_bootstrap_cache_key( + user_id: str, + *, + settings: Dict[str, Any], + user_settings_dict: Dict[str, Any], + user_groups_raw: Iterable[Dict[str, Any]], + user_visible_public_workspaces: Iterable[Dict[str, Any]], +) -> str: + """Build a stable user-scoped cache key for chat bootstrap catalog fragments.""" + versions = get_chat_bootstrap_versions(user_id) + relevant_settings = { + key: settings.get(key) + for key in ( + "allow_user_agents", + "enable_semantic_kernel", + "enable_group_workspaces", + "allow_group_agents", + "enable_multi_model_endpoints", + "allow_user_custom_endpoints", + "allow_group_custom_endpoints", + "enable_public_workspaces", + "enable_user_workspace", + "allow_user_plugins", + "allow_group_plugins", + ) + } + fingerprint = _stable_hash({ + "user_id": user_id, + "versions": versions, + "settings": relevant_settings, + "personal_model_endpoints": user_settings_dict.get("personal_model_endpoints", []), + "group_ids": _normalize_id_list(user_groups_raw), + "public_workspace_ids": _normalize_id_list(user_visible_public_workspaces), + }) + return f"user:{user_id}:{fingerprint}" + + +def get_cached_chat_bootstrap_payload(cache_key: str) -> Optional[Dict[str, Any]]: + """Return a cached chat bootstrap payload or None on miss/failure.""" + cached = get_shared_cache_entry( + CHAT_BOOTSTRAP_CACHE_NAMESPACE, + cache_key, + redis_client=_get_redis_client(), + allow_cosmos_fallback=False, + ) + if not isinstance(cached, dict): + log_event( + "[ChatBootstrapCache] Chat bootstrap cache miss.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16]}, + level=logging.INFO, + debug_only=True, + ) + return None + + log_event( + "[ChatBootstrapCache] Chat bootstrap cache hit.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16]}, + level=logging.INFO, + debug_only=True, + ) + return copy.deepcopy(cached) + + +def set_cached_chat_bootstrap_payload(cache_key: str, payload: Dict[str, Any], ttl_seconds: int = None) -> bool: + """Persist a chat bootstrap payload without failing the caller on cache errors.""" + return set_shared_cache_entry( + CHAT_BOOTSTRAP_CACHE_NAMESPACE, + cache_key, + copy.deepcopy(payload or {}), + ttl_seconds=ttl_seconds or CHAT_BOOTSTRAP_DEFAULT_TTL_SECONDS, + redis_client=_get_redis_client(), + allow_cosmos_fallback=False, + ) + + +def bump_chat_bootstrap_global_cache_version(reason: str = "global_bootstrap_change") -> Optional[int]: + """Invalidate all chat bootstrap payloads by bumping the global version.""" + try: + version = bump_shared_cache_version( + CHAT_BOOTSTRAP_GLOBAL_VERSION_DOC_ID, + description="Global chat bootstrap catalog cache version.", + ) + log_event( + "[ChatBootstrapCache] Global chat bootstrap cache invalidated.", + extra={"reason": reason, "version": version}, + level=logging.INFO, + debug_only=True, + ) + return version + except Exception as ex: + log_event( + "[ChatBootstrapCache] Failed to invalidate global chat bootstrap cache.", + extra={"reason": reason, "error": str(ex)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + +def bump_chat_bootstrap_user_cache_version(user_id: str, reason: str = "user_bootstrap_change") -> Optional[int]: + """Invalidate one user's chat bootstrap payloads by bumping the user version.""" + try: + version = bump_shared_cache_version( + _get_user_version_doc_id(user_id), + description="User chat bootstrap catalog cache version.", + ) + log_event( + "[ChatBootstrapCache] User chat bootstrap cache invalidated.", + extra={"reason": reason, "user_id": user_id, "version": version}, + level=logging.INFO, + debug_only=True, + ) + return version + except Exception as ex: + log_event( + "[ChatBootstrapCache] Failed to invalidate user chat bootstrap cache.", + extra={"reason": reason, "user_id": user_id, "error": str(ex)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py index 2e08ae11..a3fcd46f 100644 --- a/application/single_app/functions_collaboration.py +++ b/application/single_app/functions_collaboration.py @@ -36,6 +36,7 @@ ) from functions_activity_logging import log_chat_activity from functions_appinsights import log_event +from functions_conversation_cache import bump_conversation_cache_version, invalidate_conversation_cache_for_item from functions_documents import sync_chat_upload_workspace_document_sharing_for_collaboration from functions_group import ( assert_group_role, @@ -1214,6 +1215,7 @@ def create_personal_collaboration_conversation_record(title, creator_user, invit cosmos_collaboration_user_state_container.upsert_item(state_doc) user_states.append(state_doc) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_created") log_event( '[Collaboration] Created personal collaborative conversation', extra={ @@ -1255,6 +1257,7 @@ def create_group_collaboration_conversation_record(title, creator_user, group_do ) user_states.append(state_doc) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_created") log_event( '[Collaboration] Created group collaborative conversation', extra={ @@ -1539,6 +1542,7 @@ def record_personal_invite_response(conversation_id, user_id, action): cosmos_collaboration_conversations_container.upsert_item(conversation_doc) cosmos_collaboration_user_state_container.upsert_item(user_state) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_invite_response") if membership_status == MEMBERSHIP_STATUS_ACCEPTED and is_personal_collaboration_conversation(conversation_doc): sync_chat_upload_workspace_document_sharing_for_collaboration(conversation_doc) return conversation_doc, user_state, participant_record @@ -1607,6 +1611,7 @@ def invite_personal_collaboration_participants(conversation_id, owner_user_id, p cosmos_collaboration_user_state_container.upsert_item(state_doc) created_state_docs.append(state_doc) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_participants_invited") return conversation_doc, created_state_docs @@ -1667,6 +1672,8 @@ def remove_personal_collaboration_member(conversation_id, owner_user_id, member_ if is_personal_collaboration_conversation(conversation_doc): sync_chat_upload_workspace_document_sharing_for_collaboration(conversation_doc) + bump_conversation_cache_version(member_user_id, reason="collaboration_member_removed") + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_member_removed") return conversation_doc, removed_participant @@ -1745,6 +1752,7 @@ def _save_collaboration_message_doc(conversation_doc, message_doc): refresh_personal_participant_indexes(conversation_doc) cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_message_saved") if sender_user_id and sender_user_id != 'assistant' and str(message_doc.get('role') or '').strip().lower() == 'user': try: @@ -1807,6 +1815,7 @@ def sync_collaboration_conversation_metadata_from_source(conversation_doc, sourc if updated: cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_metadata_synced") return conversation_doc, updated @@ -1875,6 +1884,7 @@ def ensure_collaboration_source_conversation(conversation_doc, current_user): if source_updated: source_conversation_doc['last_updated'] = timestamp cosmos_conversations_container.upsert_item(source_conversation_doc) + invalidate_conversation_cache_for_item(source_conversation_doc, reason="collaboration_source_synced") if str((conversation_doc or {}).get('source_conversation_id') or '').strip() != source_conversation_id: conversation_doc['source_conversation_id'] = source_conversation_id @@ -1950,6 +1960,7 @@ def _refresh_collaboration_conversation_message_summary(conversation_doc): conversation_doc['updated_at'] = utc_now_iso() cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_message_summary_refreshed") return conversation_doc @@ -2015,6 +2026,7 @@ def update_personal_collaboration_title(conversation_id, current_user_id, new_ti conversation_doc['title'] = normalized_title conversation_doc['updated_at'] = utc_now_iso() cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_title_updated") return conversation_doc @@ -2050,6 +2062,7 @@ def toggle_personal_collaboration_pin(conversation_id, current_user_id): user_state['is_pinned'] = not bool(user_state.get('is_pinned', False)) user_state['updated_at'] = utc_now_iso() cosmos_collaboration_user_state_container.upsert_item(user_state) + bump_conversation_cache_version(current_user_id, reason="collaboration_pin_toggled") return conversation_doc, user_state @@ -2085,6 +2098,7 @@ def toggle_personal_collaboration_hide(conversation_id, current_user_id): user_state['is_hidden'] = not bool(user_state.get('is_hidden', False)) user_state['updated_at'] = utc_now_iso() cosmos_collaboration_user_state_container.upsert_item(user_state) + bump_conversation_cache_version(current_user_id, reason="collaboration_hide_toggled") return conversation_doc, user_state @@ -2133,6 +2147,7 @@ def update_personal_collaboration_member_role(conversation_id, current_user_id, user_state['updated_at'] = timestamp cosmos_collaboration_user_state_container.upsert_item(user_state) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_member_role_updated") return conversation_doc, participant @@ -2216,6 +2231,8 @@ def leave_personal_collaboration_conversation(conversation_id, current_user_id, if is_personal_collaboration_conversation(conversation_doc): sync_chat_upload_workspace_document_sharing_for_collaboration(conversation_doc) + bump_conversation_cache_version(current_user_id, reason="collaboration_left") + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_left") return conversation_doc, participant, promoted_participant @@ -2348,6 +2365,7 @@ def delete_personal_collaboration_conversation(conversation_id, current_user_id) item=conversation_id, partition_key=conversation_id, ) + invalidate_conversation_cache_for_item(conversation_doc, reason="collaboration_deleted") return conversation_doc diff --git a/application/single_app/functions_conversation_cache.py b/application/single_app/functions_conversation_cache.py new file mode 100644 index 00000000..dc539b43 --- /dev/null +++ b/application/single_app/functions_conversation_cache.py @@ -0,0 +1,579 @@ +# functions_conversation_cache.py +"""Versioned conversation list/search cache helpers.""" + +import copy +import hashlib +import json +import logging +import threading +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Iterable, Optional + +import app_settings_cache +from functions_appinsights import log_event +from functions_group import find_group_by_id +from functions_shared_cache import ( + get_shared_cache_entry, + set_shared_cache_entry, +) + + +CONVERSATION_CACHE_NAMESPACE = "conversation_cache" +CONVERSATION_CACHE_VERSION_DOC_PREFIX = "conversation_cache_version:" +CONVERSATION_CACHE_ENABLED_SETTING = "enable_conversation_cache" +CONVERSATION_CACHE_TTL_SECONDS_SETTING = "conversation_cache_ttl_seconds" +CONVERSATION_CACHE_DEFAULT_TTL_SECONDS = 120 +CONVERSATION_CACHE_METRIC_WINDOWS_MINUTES = (5, 15, 60) +CONVERSATION_CACHE_METRIC_RETENTION_MINUTES = 60 +CONVERSATION_CACHE_METRIC_MAX_SAMPLES = 2000 + +_conversation_cache_metric_samples = [] +_conversation_cache_metric_lock = threading.Lock() + + +def _normalize_bool(value: Any, default: bool = True) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def get_conversation_cache_ttl_seconds(settings: Optional[Dict[str, Any]] = None) -> int: + """Return a safe positive TTL for conversation cache entries.""" + raw_ttl_seconds = CONVERSATION_CACHE_DEFAULT_TTL_SECONDS + if isinstance(settings, dict): + raw_ttl_seconds = settings.get( + CONVERSATION_CACHE_TTL_SECONDS_SETTING, + CONVERSATION_CACHE_DEFAULT_TTL_SECONDS, + ) + try: + return max(int(raw_ttl_seconds), 0) + except (TypeError, ValueError): + log_event( + "[ConversationCache] Invalid conversation cache TTL; using default.", + extra={"ttl_seconds": raw_ttl_seconds}, + level=logging.WARNING, + ) + return CONVERSATION_CACHE_DEFAULT_TTL_SECONDS + + +def get_conversation_cache_settings(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Return normalized conversation cache settings with Redis remaining optional.""" + settings = settings if isinstance(settings, dict) else {} + return { + "enabled": _normalize_bool(settings.get(CONVERSATION_CACHE_ENABLED_SETTING), default=True), + "ttl_seconds": get_conversation_cache_ttl_seconds(settings), + } + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_utc_datetime(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + parsed = value + else: + try: + parsed = datetime.fromisoformat(str(value or "")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _metric_percent(numerator: int, denominator: int) -> Optional[float]: + if denominator <= 0: + return None + return round((float(numerator) / float(denominator)) * 100, 2) + + +def _safe_int(value: Any) -> Optional[int]: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _increment_count(counts: Dict[str, int], key: Any) -> None: + normalized_key = str(key or "unknown").strip().lower() or "unknown" + counts[normalized_key] = int(counts.get(normalized_key) or 0) + 1 + + +def _get_cache_operation_from_key(cache_key: Any) -> str: + normalized_key = str(cache_key or "").strip() + operation = normalized_key.split(":", 1)[0].strip().lower() + return operation or "conversation_cache" + + +def _empty_conversation_cache_metric_window(window_minutes: int) -> Dict[str, Any]: + return { + "window_minutes": window_minutes, + "sample_count": 0, + "hit_count": 0, + "miss_count": 0, + "bypass_count": 0, + "write_count": 0, + "invalidation_count": 0, + "error_count": 0, + "hit_rate_percent": None, + "operation_counts": {}, + "event_counts": {}, + "reason_counts": {}, + "first_checked_at": None, + "last_checked_at": None, + } + + +def _prune_conversation_cache_metric_samples(samples: Iterable[Dict[str, Any]], now: datetime) -> list: + cutoff = now - timedelta(minutes=CONVERSATION_CACHE_METRIC_RETENTION_MINUTES) + pruned_samples = [] + for sample in samples or []: + if not isinstance(sample, dict): + continue + checked_at = _parse_utc_datetime(sample.get("checked_at")) + if not checked_at or checked_at < cutoff: + continue + pruned_samples.append(copy.deepcopy(sample)) + + pruned_samples.sort(key=lambda sample: sample.get("checked_at") or "") + return pruned_samples[-CONVERSATION_CACHE_METRIC_MAX_SAMPLES:] + + +def _conversation_cache_metric_matches(sample: Dict[str, Any], event_types: set) -> bool: + return str(sample.get("event_type") or "").strip().lower() in event_types + + +def _build_conversation_cache_metric_window( + samples: Iterable[Dict[str, Any]], + now: datetime, + window_minutes: int, +) -> Dict[str, Any]: + cutoff = now - timedelta(minutes=window_minutes) + window_samples = [ + sample + for sample in samples or [] + if (_parse_utc_datetime(sample.get("checked_at")) or datetime.min.replace(tzinfo=timezone.utc)) >= cutoff + ] + if not window_samples: + return _empty_conversation_cache_metric_window(window_minutes) + + operation_counts = {} + event_counts = {} + reason_counts = {} + for sample in window_samples: + _increment_count(operation_counts, sample.get("operation")) + _increment_count(event_counts, sample.get("event_type")) + _increment_count(reason_counts, sample.get("reason")) + + hit_count = sum(1 for sample in window_samples if _conversation_cache_metric_matches(sample, {"hit"})) + miss_count = sum(1 for sample in window_samples if _conversation_cache_metric_matches(sample, {"miss"})) + bypass_count = sum(1 for sample in window_samples if _conversation_cache_metric_matches(sample, {"bypass"})) + write_count = sum(1 for sample in window_samples if _conversation_cache_metric_matches(sample, {"write"})) + invalidation_count = sum( + 1 for sample in window_samples if _conversation_cache_metric_matches(sample, {"invalidate"}) + ) + error_count = sum( + 1 + for sample in window_samples + if _conversation_cache_metric_matches(sample, {"read_failed", "write_failed", "invalidate_failed"}) + ) + return { + "window_minutes": window_minutes, + "sample_count": len(window_samples), + "hit_count": hit_count, + "miss_count": miss_count, + "bypass_count": bypass_count, + "write_count": write_count, + "invalidation_count": invalidation_count, + "error_count": error_count, + "hit_rate_percent": _metric_percent(hit_count, hit_count + miss_count), + "operation_counts": operation_counts, + "event_counts": event_counts, + "reason_counts": reason_counts, + "first_checked_at": window_samples[0].get("checked_at"), + "last_checked_at": window_samples[-1].get("checked_at"), + } + + +def _build_conversation_cache_metrics(samples: Iterable[Dict[str, Any]], now: datetime) -> Dict[str, Any]: + error_samples = [ + sample + for sample in samples or [] + if str(sample.get("event_type") or "").strip().lower().endswith("_failed") + ] + invalidation_samples = [ + sample + for sample in samples or [] + if str(sample.get("event_type") or "").strip().lower() == "invalidate" + ] + return { + "updated_at": now.isoformat(), + "retention_minutes": CONVERSATION_CACHE_METRIC_RETENTION_MINUTES, + "sample_limit": CONVERSATION_CACHE_METRIC_MAX_SAMPLES, + "sample_count": len(samples or []), + "last_event": copy.deepcopy(samples[-1]) if samples else None, + "last_error": copy.deepcopy(error_samples[-1]) if error_samples else None, + "last_invalidation": copy.deepcopy(invalidation_samples[-1]) if invalidation_samples else None, + "windows": { + f"{window_minutes}m": _build_conversation_cache_metric_window(samples, now, window_minutes) + for window_minutes in CONVERSATION_CACHE_METRIC_WINDOWS_MINUTES + }, + } + + +def _record_conversation_cache_metric( + event_type: str, + operation: str = None, + reason: str = None, + ttl_seconds: int = None, + error: Exception = None, +) -> Dict[str, Any]: + sample = { + "checked_at": _utc_now_iso(), + "event_type": str(event_type or "unknown").strip().lower() or "unknown", + "operation": str(operation or "conversation_cache").strip().lower() or "conversation_cache", + "reason": str(reason or "").strip().lower() or None, + "ttl_seconds": _safe_int(ttl_seconds), + } + if error: + sample["error_type"] = type(error).__name__ + + with _conversation_cache_metric_lock: + now = datetime.now(timezone.utc) + samples = _prune_conversation_cache_metric_samples(_conversation_cache_metric_samples, now) + samples.append(sample) + del samples[:-CONVERSATION_CACHE_METRIC_MAX_SAMPLES] + _conversation_cache_metric_samples[:] = samples + return sample + + +def get_conversation_cache_metrics() -> Dict[str, Any]: + """Return lightweight in-process rolling metrics for conversation cache reads/writes.""" + with _conversation_cache_metric_lock: + samples = copy.deepcopy(_conversation_cache_metric_samples) + now = datetime.now(timezone.utc) + samples = _prune_conversation_cache_metric_samples(samples, now) + return _build_conversation_cache_metrics(samples, now) + + +def reset_conversation_cache_metrics() -> None: + """Reset lightweight conversation cache counters for deterministic functional tests.""" + with _conversation_cache_metric_lock: + _conversation_cache_metric_samples[:] = [] + + +def _stable_hash(payload: Any) -> str: + serialized = json.dumps(payload, sort_keys=True, default=str, separators=(",", ":")) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _get_redis_client(): + try: + return app_settings_cache.get_app_cache_redis_client() + except Exception as ex: + log_event( + "[ConversationCache] Redis client lookup failed; bypassing conversation cache.", + extra={"error": str(ex)}, + level=logging.WARNING, + ) + return None + + +def _get_version_doc_id(user_id: str) -> str: + normalized_user_id = str(user_id or "").strip() + if not normalized_user_id: + raise ValueError("user_id is required for conversation cache versioning.") + return f"{CONVERSATION_CACHE_VERSION_DOC_PREFIX}{normalized_user_id}" + + +def get_conversation_cache_version(user_id: str) -> Optional[int]: + """Return the current Redis-backed user-scoped conversation cache version.""" + redis_client = _get_redis_client() + if redis_client is None: + _record_conversation_cache_metric("bypass", operation="version", reason="redis_unavailable") + return None + try: + version = redis_client.get(_get_version_doc_id(user_id)) + if version is None: + return 0 + if isinstance(version, bytes): + version = version.decode("utf-8") + return int(version) + except Exception as ex: + _record_conversation_cache_metric("read_failed", operation="version", reason="version_read_failed", error=ex) + log_event( + "[ConversationCache] Failed to read conversation cache version.", + extra={"user_id": user_id, "error": str(ex)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + +def bump_conversation_cache_version(user_id: str, reason: str = "conversation_changed") -> Optional[int]: + """Invalidate a user's conversation list/search cache without failing callers.""" + if not user_id: + return None + redis_client = _get_redis_client() + if redis_client is None: + _record_conversation_cache_metric("bypass", operation="version", reason="redis_unavailable") + return None + try: + version = int(redis_client.incr(_get_version_doc_id(user_id)) or 0) + log_event( + "[ConversationCache] Conversation cache invalidated.", + extra={"reason": reason, "user_id": user_id, "version": version}, + level=logging.INFO, + debug_only=True, + ) + _record_conversation_cache_metric("invalidate", operation="version", reason=reason) + return version + except Exception as ex: + _record_conversation_cache_metric("invalidate_failed", operation="version", reason=reason, error=ex) + log_event( + "[ConversationCache] Failed to invalidate conversation cache.", + extra={"reason": reason, "user_id": user_id, "error": str(ex)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + +def _participant_user_ids_from_tags(tags: Iterable[Dict[str, Any]]) -> set[str]: + user_ids = set() + for tag in tags or []: + if not isinstance(tag, dict): + continue + if tag.get("category") != "participant": + continue + user_id = str(tag.get("user_id") or "").strip() + if user_id: + user_ids.add(user_id) + return user_ids + + +def _add_user_id(user_ids: set[str], user_id: Any) -> None: + normalized_user_id = str(user_id or "").strip() + if normalized_user_id: + user_ids.add(normalized_user_id) + + +def _add_user_ids(user_ids: set[str], candidate_user_ids: Iterable[Any]) -> None: + for user_id in candidate_user_ids or []: + _add_user_id(user_ids, user_id) + + +def _add_participant_user_ids(user_ids: set[str], participants: Iterable[Dict[str, Any]]) -> None: + for participant in participants or []: + if not isinstance(participant, dict): + continue + if str(participant.get("status") or "").strip().lower() == "removed": + continue + _add_user_id(user_ids, participant.get("user_id")) + + +def _add_group_member_user_ids(user_ids: set[str], group_id: Any) -> None: + normalized_group_id = str(group_id or "").strip() + if not normalized_group_id: + return + try: + group_doc = find_group_by_id(normalized_group_id) + except Exception as ex: + log_event( + "[ConversationCache] Failed to load group members for cache invalidation.", + extra={"group_id": normalized_group_id, "error": str(ex)}, + level=logging.WARNING, + exceptionTraceback=True, + debug_only=True, + ) + return + if not isinstance(group_doc, dict): + return + + owner_doc = group_doc.get("owner") if isinstance(group_doc.get("owner"), dict) else {} + _add_user_id(user_ids, owner_doc.get("id") or owner_doc.get("userId")) + for collection_name in ("admins", "documentManagers", "users"): + for member in group_doc.get(collection_name, []) or []: + if not isinstance(member, dict): + continue + _add_user_id(user_ids, member.get("userId") or member.get("id")) + + +def get_conversation_cache_user_ids(conversation_item: Dict[str, Any]) -> set[str]: + """Return users whose conversation list/search cache can include this item.""" + user_ids = set() + if not isinstance(conversation_item, dict): + return user_ids + + _add_user_id(user_ids, conversation_item.get("user_id")) + _add_user_id(user_ids, conversation_item.get("created_by_user_id")) + user_ids.update(_participant_user_ids_from_tags(conversation_item.get("tags") or [])) + _add_participant_user_ids(user_ids, conversation_item.get("participants") or []) + for field_name in ("accepted_participant_ids", "pending_participant_ids", "owner_user_ids", "admin_user_ids"): + _add_user_ids(user_ids, conversation_item.get(field_name) or []) + + scope = conversation_item.get("scope") if isinstance(conversation_item.get("scope"), dict) else {} + _add_group_member_user_ids(user_ids, scope.get("group_id") or conversation_item.get("group_id")) + return user_ids + + +def invalidate_conversation_cache_for_item(conversation_item: Dict[str, Any], reason: str = "conversation_changed") -> None: + """Invalidate cache versions for the owner and known participants on a conversation item.""" + for user_id in get_conversation_cache_user_ids(conversation_item): + bump_conversation_cache_version(user_id, reason=reason) + + +def build_conversation_cache_key(user_id: str, operation: str, parameters: Dict[str, Any] = None) -> Optional[str]: + """Build a stable user-scoped cache key for conversation list/search payloads.""" + version = get_conversation_cache_version(user_id) + if version is None: + return None + fingerprint = _stable_hash({ + "user_id": user_id, + "operation": operation, + "version": version, + "parameters": parameters or {}, + }) + return f"{operation}:{user_id}:{fingerprint}" + + +def get_cached_conversation_payload(cache_key: str, settings: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + """Return a cached conversation payload or None on cache miss/failure.""" + cache_settings = get_conversation_cache_settings(settings) + operation = _get_cache_operation_from_key(cache_key) + if not cache_settings.get("enabled"): + _record_conversation_cache_metric("bypass", operation=operation, reason="disabled") + log_event( + "[ConversationCache] Conversation cache read bypassed because cache is disabled.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16] if cache_key else ""}, + level=logging.INFO, + debug_only=True, + ) + return None + + redis_client = _get_redis_client() + if redis_client is None: + _record_conversation_cache_metric("bypass", operation=operation, reason="redis_unavailable") + return None + + try: + cached = get_shared_cache_entry( + CONVERSATION_CACHE_NAMESPACE, + cache_key, + redis_client=redis_client, + allow_cosmos_fallback=False, + ) + except Exception as ex: + _record_conversation_cache_metric("read_failed", operation=operation, error=ex) + log_event( + "[ConversationCache] Failed to read conversation cache payload.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16], "error": str(ex)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + if not isinstance(cached, dict): + _record_conversation_cache_metric("miss", operation=operation) + log_event( + "[ConversationCache] Conversation cache miss.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16]}, + level=logging.INFO, + debug_only=True, + ) + return None + + _record_conversation_cache_metric("hit", operation=operation) + log_event( + "[ConversationCache] Conversation cache hit.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16]}, + level=logging.INFO, + debug_only=True, + ) + return copy.deepcopy(cached) + + +def set_cached_conversation_payload( + cache_key: str, + payload: Dict[str, Any], + ttl_seconds: int = None, + settings: Optional[Dict[str, Any]] = None, +) -> bool: + """Persist a conversation payload without failing the caller on cache errors.""" + cache_settings = get_conversation_cache_settings(settings) + operation = _get_cache_operation_from_key(cache_key) + if not cache_settings.get("enabled"): + _record_conversation_cache_metric("bypass", operation=operation, reason="disabled") + log_event( + "[ConversationCache] Conversation cache write bypassed because cache is disabled.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16] if cache_key else ""}, + level=logging.INFO, + debug_only=True, + ) + return False + + safe_ttl_seconds = ( + get_conversation_cache_ttl_seconds({CONVERSATION_CACHE_TTL_SECONDS_SETTING: ttl_seconds}) + if ttl_seconds is not None + else cache_settings.get("ttl_seconds", CONVERSATION_CACHE_DEFAULT_TTL_SECONDS) + ) + if safe_ttl_seconds <= 0: + _record_conversation_cache_metric( + "bypass", + operation=operation, + reason="ttl_disabled", + ttl_seconds=safe_ttl_seconds, + ) + log_event( + "[ConversationCache] Conversation cache write bypassed because TTL is disabled.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16] if cache_key else ""}, + level=logging.INFO, + debug_only=True, + ) + return False + + redis_client = _get_redis_client() + if redis_client is None: + _record_conversation_cache_metric("bypass", operation=operation, reason="redis_unavailable") + return False + + try: + cache_written = set_shared_cache_entry( + CONVERSATION_CACHE_NAMESPACE, + cache_key, + copy.deepcopy(payload or {}), + ttl_seconds=safe_ttl_seconds, + redis_client=redis_client, + allow_cosmos_fallback=False, + ) + _record_conversation_cache_metric( + "write" if cache_written else "write_failed", + operation=operation, + ttl_seconds=safe_ttl_seconds, + ) + return cache_written + except Exception as ex: + _record_conversation_cache_metric( + "write_failed", + operation=operation, + ttl_seconds=safe_ttl_seconds, + error=ex, + ) + log_event( + "[ConversationCache] Failed to write conversation cache payload.", + extra={"cache_key_hash": _stable_hash(cache_key)[:16], "error": str(ex)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return False diff --git a/application/single_app/functions_conversation_metadata.py b/application/single_app/functions_conversation_metadata.py index 3b79e771..efd850e2 100644 --- a/application/single_app/functions_conversation_metadata.py +++ b/application/single_app/functions_conversation_metadata.py @@ -8,6 +8,7 @@ from functions_public_workspaces import find_public_workspace_by_id from functions_documents import get_document_metadata from functions_collaboration import get_collaboration_conversation +from functions_conversation_cache import invalidate_conversation_cache_for_item from functions_debug import debug_print def get_user_info_by_id(user_id): @@ -861,6 +862,7 @@ def update_conversation_with_metadata(conversation_id, metadata_updates): else: conversation_item['last_updated'] = updated_at cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_metadata_updated") return True diff --git a/application/single_app/functions_cosmos_indexing.py b/application/single_app/functions_cosmos_indexing.py new file mode 100644 index 00000000..a55dc066 --- /dev/null +++ b/application/single_app/functions_cosmos_indexing.py @@ -0,0 +1,355 @@ +# functions_cosmos_indexing.py +"""Cosmos DB indexing policy comparison and maintenance helpers.""" + +import copy +import logging +from datetime import datetime, timezone + +from azure.core import MatchConditions +from azure.cosmos import PartitionKey + +from config import ( + cosmos_collaboration_messages_container, + cosmos_collaboration_messages_container_name, + cosmos_conversations_container, + cosmos_conversations_container_name, + cosmos_database, + cosmos_group_documents_container, + cosmos_group_documents_container_name, + cosmos_messages_container, + cosmos_messages_container_name, + cosmos_public_documents_container, + cosmos_public_documents_container_name, + cosmos_user_documents_container, + cosmos_user_documents_container_name, +) +from functions_appinsights import log_event + + +COSMOS_INDEXING_POLICY_DEFINITION_VERSION = 1 +COSMOS_INDEXING_POLICY_APPLY_SETTING = 'app_maintenance_apply_cosmos_indexing_policies' +COSMOS_INDEXING_POLICY_MAX_REPLACE_RETRIES = 3 + + +def _utc_now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def _composite_index(*paths): + return [ + { + 'path': path, + 'order': order, + } + for path, order in paths + ] + + +COSMOS_INDEXING_POLICY_DEFINITIONS = [ + { + 'container_name': cosmos_conversations_container_name, + 'container': cosmos_conversations_container, + 'partition_key_path': '/id', + 'description': 'Personal conversation list, feed, and search ordering by user and last update.', + 'expected_policy': { + 'compositeIndexes': [ + _composite_index(('/user_id', 'ascending'), ('/last_updated', 'descending')), + _composite_index( + ('/user_id', 'ascending'), + ('/is_hidden', 'ascending'), + ('/last_updated', 'descending'), + ), + ], + }, + }, + { + 'container_name': cosmos_messages_container_name, + 'container': cosmos_messages_container, + 'partition_key_path': '/conversation_id', + 'description': 'Personal chat message ordering by timestamp and retry-thread attempt metadata.', + 'expected_policy': { + 'compositeIndexes': [ + _composite_index(('/conversation_id', 'ascending'), ('/timestamp', 'ascending')), + _composite_index(('/conversation_id', 'ascending'), ('/timestamp', 'descending')), + _composite_index( + ('/conversation_id', 'ascending'), + ('/metadata/thread_info/thread_id', 'ascending'), + ('/role', 'ascending'), + ('/metadata/thread_info/thread_attempt', 'ascending'), + ), + ], + }, + }, + { + 'container_name': cosmos_collaboration_messages_container_name, + 'container': cosmos_collaboration_messages_container, + 'partition_key_path': '/conversation_id', + 'description': 'Collaboration chat message ordering by timestamp.', + 'expected_policy': { + 'compositeIndexes': [ + _composite_index(('/conversation_id', 'ascending'), ('/timestamp', 'ascending')), + _composite_index(('/conversation_id', 'ascending'), ('/timestamp', 'descending')), + ], + }, + }, + { + 'container_name': cosmos_user_documents_container_name, + 'container': cosmos_user_documents_container, + 'partition_key_path': '/id', + 'description': 'Personal document version lookups before the document access index read model.', + 'expected_policy': { + 'compositeIndexes': [ + _composite_index(('/id', 'ascending'), ('/version', 'descending')), + _composite_index(('/id', 'ascending'), ('/user_id', 'ascending'), ('/version', 'descending')), + ], + }, + }, + { + 'container_name': cosmos_group_documents_container_name, + 'container': cosmos_group_documents_container, + 'partition_key_path': '/id', + 'description': 'Group document version lookups before the document access index read model.', + 'expected_policy': { + 'compositeIndexes': [ + _composite_index(('/id', 'ascending'), ('/version', 'descending')), + _composite_index(('/id', 'ascending'), ('/group_id', 'ascending'), ('/version', 'descending')), + ], + }, + }, + { + 'container_name': cosmos_public_documents_container_name, + 'container': cosmos_public_documents_container, + 'partition_key_path': '/id', + 'description': 'Public workspace document version lookups before the document access index read model.', + 'expected_policy': { + 'compositeIndexes': [ + _composite_index(('/id', 'ascending'), ('/version', 'descending')), + _composite_index( + ('/id', 'ascending'), + ('/public_workspace_id', 'ascending'), + ('/version', 'descending'), + ), + ], + }, + }, +] + + +def _normalize_composite_path(path_definition): + path = str((path_definition or {}).get('path') or '').strip() + if path and not path.startswith('/'): + path = f'/{path}' + order = str((path_definition or {}).get('order') or 'ascending').strip().lower() + if order not in ('ascending', 'descending'): + order = 'ascending' + return { + 'path': path, + 'order': order, + } + + +def _canonical_composite_index(composite_index): + return tuple( + ( + normalized_path['path'], + normalized_path['order'], + ) + for normalized_path in [_normalize_composite_path(path) for path in list(composite_index or [])] + if normalized_path['path'] + ) + + +def _normalize_composite_index(composite_index): + return [ + { + 'path': path, + 'order': order, + } + for path, order in _canonical_composite_index(composite_index) + ] + + +def _get_current_indexing_policy(container_properties): + current_policy = copy.deepcopy((container_properties or {}).get('indexingPolicy') or {}) + current_policy.setdefault('indexingMode', 'consistent') + current_policy.setdefault('automatic', True) + current_policy.setdefault('includedPaths', [{'path': '/*'}]) + current_policy.setdefault('excludedPaths', [{'path': '/"_etag"/?'}]) + current_policy['compositeIndexes'] = list(current_policy.get('compositeIndexes') or []) + return current_policy + + +def _merge_expected_indexing_policy(current_policy, expected_policy): + desired_policy = copy.deepcopy(current_policy or {}) + current_composite_indexes = list(desired_policy.get('compositeIndexes') or []) + current_canonical_indexes = { + _canonical_composite_index(index) + for index in current_composite_indexes + if _canonical_composite_index(index) + } + + missing_indexes = [] + for expected_index in list((expected_policy or {}).get('compositeIndexes') or []): + normalized_expected_index = _normalize_composite_index(expected_index) + canonical_expected_index = _canonical_composite_index(normalized_expected_index) + if not canonical_expected_index: + continue + if canonical_expected_index in current_canonical_indexes: + continue + missing_indexes.append(normalized_expected_index) + current_composite_indexes.append(normalized_expected_index) + current_canonical_indexes.add(canonical_expected_index) + + desired_policy['compositeIndexes'] = current_composite_indexes + return desired_policy, missing_indexes + + +def _is_precondition_failed_error(exc): + return getattr(exc, 'status_code', None) == 412 + + +def _replace_container_indexing_policy(definition, container_properties, desired_policy): + replace_kwargs = {} + etag = (container_properties or {}).get('_etag') + if etag: + replace_kwargs['etag'] = etag + replace_kwargs['match_condition'] = MatchConditions.IfNotModified + + if 'defaultTtl' in (container_properties or {}): + replace_kwargs['default_ttl'] = container_properties.get('defaultTtl') + if 'analyticalStorageTtl' in (container_properties or {}): + replace_kwargs['analytical_storage_ttl'] = container_properties.get('analyticalStorageTtl') + if 'conflictResolutionPolicy' in (container_properties or {}): + replace_kwargs['conflict_resolution_policy'] = container_properties.get('conflictResolutionPolicy') + if 'fullTextPolicy' in (container_properties or {}): + replace_kwargs['full_text_policy'] = container_properties.get('fullTextPolicy') + + cosmos_database.replace_container( + container=definition['container_name'], + partition_key=PartitionKey(path=definition['partition_key_path']), + indexing_policy=desired_policy, + **replace_kwargs, + ) + + +def _evaluate_single_indexing_policy(definition, apply_changes=False): + for attempt in range(COSMOS_INDEXING_POLICY_MAX_REPLACE_RETRIES): + container_properties = definition['container'].read() + current_policy = _get_current_indexing_policy(container_properties) + desired_policy, missing_indexes = _merge_expected_indexing_policy( + current_policy, + definition.get('expected_policy', {}), + ) + + result = { + 'container_name': definition['container_name'], + 'partition_key_path': definition['partition_key_path'], + 'description': definition.get('description', ''), + 'status': 'aligned' if not missing_indexes else 'missing_expected_indexes', + 'apply_requested': bool(apply_changes), + 'applied': False, + 'definition_version': COSMOS_INDEXING_POLICY_DEFINITION_VERSION, + 'current_composite_index_count': len(current_policy.get('compositeIndexes') or []), + 'expected_composite_index_count': len( + list((definition.get('expected_policy') or {}).get('compositeIndexes') or []) + ), + 'missing_composite_index_count': len(missing_indexes), + 'missing_composite_indexes': missing_indexes, + 'index_transformation_progress': container_properties.get('indexTransformationProgress'), + } + + if not missing_indexes or not apply_changes: + return result + + try: + _replace_container_indexing_policy(definition, container_properties, desired_policy) + result.update({ + 'status': 'updated', + 'applied': True, + 'submitted_at': _utc_now_iso(), + 'desired_composite_index_count': len(desired_policy.get('compositeIndexes') or []), + }) + return result + except Exception as exc: + if _is_precondition_failed_error(exc) and attempt < COSMOS_INDEXING_POLICY_MAX_REPLACE_RETRIES - 1: + log_event( + '[CosmosIndexing] Retrying indexing policy update after ETag conflict.', + extra={ + 'container_name': definition['container_name'], + 'attempt': attempt + 1, + }, + level=logging.WARNING, + debug_only=True, + ) + continue + raise + + raise RuntimeError(f"Failed to update indexing policy for {definition['container_name']} after retries.") + + +def list_expected_cosmos_indexing_policies(): + """Return JSON-safe expected Cosmos indexing policy definitions.""" + return [ + { + 'container_name': definition['container_name'], + 'partition_key_path': definition['partition_key_path'], + 'description': definition.get('description', ''), + 'definition_version': COSMOS_INDEXING_POLICY_DEFINITION_VERSION, + 'expected_composite_indexes': [ + _normalize_composite_index(index) + for index in list((definition.get('expected_policy') or {}).get('compositeIndexes') or []) + ], + } + for definition in COSMOS_INDEXING_POLICY_DEFINITIONS + ] + + +def run_cosmos_indexing_policy_maintenance(apply_changes=False): + """Compare or apply expected Cosmos indexing policies for configured hot containers.""" + results = [] + for definition in COSMOS_INDEXING_POLICY_DEFINITIONS: + try: + results.append(_evaluate_single_indexing_policy(definition, apply_changes=apply_changes)) + except Exception as exc: + log_event( + '[CosmosIndexing] Failed to evaluate Cosmos indexing policy.', + extra={ + 'container_name': definition.get('container_name'), + 'apply_changes': bool(apply_changes), + 'error': str(exc), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + results.append({ + 'container_name': definition.get('container_name'), + 'partition_key_path': definition.get('partition_key_path'), + 'description': definition.get('description', ''), + 'status': 'failed', + 'apply_requested': bool(apply_changes), + 'applied': False, + 'definition_version': COSMOS_INDEXING_POLICY_DEFINITION_VERSION, + 'error': str(exc), + }) + + failed_count = sum(1 for result in results if result.get('status') == 'failed') + missing_count = sum(1 for result in results if result.get('missing_composite_index_count', 0)) + updated_count = sum(1 for result in results if result.get('applied')) + + return { + 'success': failed_count == 0, + 'mode': 'apply' if apply_changes else 'report_only', + 'apply_setting': COSMOS_INDEXING_POLICY_APPLY_SETTING, + 'definition_version': COSMOS_INDEXING_POLICY_DEFINITION_VERSION, + 'container_count': len(results), + 'failed_container_count': failed_count, + 'containers_missing_expected_indexes': missing_count, + 'updated_container_count': updated_count, + 'evaluated_at': _utc_now_iso(), + 'containers': results, + } + + +def get_cosmos_indexing_policy_status(): + """Return report-only Cosmos indexing policy status for admin diagnostics.""" + return run_cosmos_indexing_policy_maintenance(apply_changes=False) diff --git a/application/single_app/functions_cosmos_stale_cleanup.py b/application/single_app/functions_cosmos_stale_cleanup.py new file mode 100644 index 00000000..ff93e6ea --- /dev/null +++ b/application/single_app/functions_cosmos_stale_cleanup.py @@ -0,0 +1,349 @@ +# functions_cosmos_stale_cleanup.py +"""Allowlisted Cosmos settings-container cleanup helpers for stale cache artifacts.""" + +import copy +import hashlib +import logging +from datetime import datetime, timezone + +from config import cosmos_settings_container +from functions_appinsights import log_event + + +COSMOS_STALE_CACHE_CLEANUP_DEFINITION_VERSION = 1 +COSMOS_STALE_CACHE_CLEANUP_RUN_SETTING = "app_maintenance_run_stale_cache_cleanup" +COSMOS_STALE_CACHE_CLEANUP_APPLY_SETTING = "app_maintenance_apply_stale_cache_cleanup" +COSMOS_STALE_CACHE_CLEANUP_BATCH_SIZE_SETTING = "app_maintenance_stale_cache_cleanup_batch_size" +COSMOS_STALE_CACHE_CLEANUP_DEFAULT_BATCH_SIZE = 100 +COSMOS_STALE_CACHE_CLEANUP_MAX_BATCH_SIZE = 500 + +SHARED_CACHE_ENTRY_DOC_TYPE = "shared_cache_entry" +SHARED_CACHE_ENTRY_PREFIX = "shared_cache_entry:" +SHARED_CACHE_VERSION_DOC_TYPE = "cache_version" +CONVERSATION_CACHE_NAMESPACE = "conversation_cache" +CHAT_BOOTSTRAP_NAMESPACE = "chat_bootstrap" +CONVERSATION_CACHE_VERSION_DOC_PREFIX = "conversation_cache_version:" +CONVERSATION_CACHE_ENTRY_PREFIX = f"{SHARED_CACHE_ENTRY_PREFIX}{CONVERSATION_CACHE_NAMESPACE}:" +CHAT_BOOTSTRAP_ENTRY_PREFIX = f"{SHARED_CACHE_ENTRY_PREFIX}{CHAT_BOOTSTRAP_NAMESPACE}:" + +CLEANUP_CATEGORIES = { + "legacy_conversation_cache_versions": { + "description": "Redis-only conversation cache version documents from the retired Cosmos fallback path.", + }, + "redis_only_shared_cache_entries": { + "description": "Volatile conversation/chat bootstrap cache payloads that no longer use Cosmos fallback.", + }, + "expired_shared_cache_entries": { + "description": "Expired Cosmos fallback cache payloads that can be recomputed on demand.", + }, +} + + +def _utc_now(): + return datetime.now(timezone.utc) + + +def _utc_now_iso(): + return _utc_now().isoformat() + + +def _is_not_found_error(exc): + return getattr(exc, "status_code", None) == 404 + + +def _hash_document_id(document_id): + normalized_id = str(document_id or "").strip() + if not normalized_id: + return "" + return hashlib.sha256(normalized_id.encode("utf-8")).hexdigest()[:16] + + +def _normalize_batch_size(batch_size): + try: + normalized_batch_size = int(batch_size or COSMOS_STALE_CACHE_CLEANUP_DEFAULT_BATCH_SIZE) + except (TypeError, ValueError): + normalized_batch_size = COSMOS_STALE_CACHE_CLEANUP_DEFAULT_BATCH_SIZE + return min(max(normalized_batch_size, 1), COSMOS_STALE_CACHE_CLEANUP_MAX_BATCH_SIZE) + + +def _parse_utc_datetime(value): + if isinstance(value, datetime): + parsed = value + else: + try: + parsed = datetime.fromisoformat(str(value or "")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _is_expired_shared_cache_entry(document, now): + if not isinstance(document, dict) or document.get("type") != SHARED_CACHE_ENTRY_DOC_TYPE: + return False + expires_at = document.get("expires_at") + if not expires_at: + return False + parsed_expires_at = _parse_utc_datetime(expires_at) + if parsed_expires_at is None: + return True + return parsed_expires_at <= now + + +def _classify_cleanup_candidate(document, now): + if not isinstance(document, dict): + return None + + document_id = str(document.get("id") or "") + document_type = str(document.get("type") or "") + namespace = str(document.get("namespace") or "") + if ( + document_type == SHARED_CACHE_VERSION_DOC_TYPE + and document_id.startswith(CONVERSATION_CACHE_VERSION_DOC_PREFIX) + ): + return "legacy_conversation_cache_versions" + + if document_type == SHARED_CACHE_ENTRY_DOC_TYPE and ( + namespace in {CONVERSATION_CACHE_NAMESPACE, CHAT_BOOTSTRAP_NAMESPACE} + or document_id.startswith(CONVERSATION_CACHE_ENTRY_PREFIX) + or document_id.startswith(CHAT_BOOTSTRAP_ENTRY_PREFIX) + ): + return "redis_only_shared_cache_entries" + + if _is_expired_shared_cache_entry(document, now): + return "expired_shared_cache_entries" + + return None + + +def _empty_category_counts(): + return { + category_id: { + "candidate_count": 0, + "deleted_count": 0, + "skipped_count": 0, + "failed_count": 0, + } + for category_id in CLEANUP_CATEGORIES + } + + +def _increment_category_count(category_counts, category_id, count_name): + if category_id not in category_counts: + return + category_counts[category_id][count_name] = int(category_counts[category_id].get(count_name) or 0) + 1 + + +def _build_category_summary(category_counts): + return [ + { + "category": category_id, + "description": CLEANUP_CATEGORIES[category_id]["description"], + **counts, + } + for category_id, counts in category_counts.items() + ] + + +def _query_stale_cache_cleanup_candidates(container, batch_size, now_iso): + query_limit = _normalize_batch_size(batch_size) + query = f""" + SELECT TOP {query_limit} + c.id, + c.type, + c.namespace, + c.key, + c.expires_at, + c.updated_at + FROM c + WHERE + ( + c.type = @cacheVersionType + AND STARTSWITH(c.id, @conversationVersionPrefix) + ) + OR + ( + c.type = @entryType + AND ( + c.namespace = @conversationNamespace + OR c.namespace = @chatBootstrapNamespace + OR STARTSWITH(c.id, @conversationEntryPrefix) + OR STARTSWITH(c.id, @chatBootstrapEntryPrefix) + OR ( + IS_DEFINED(c.expires_at) + AND c.expires_at != null + AND c.expires_at <= @nowIso + ) + ) + ) + """ + parameters = [ + {"name": "@cacheVersionType", "value": SHARED_CACHE_VERSION_DOC_TYPE}, + {"name": "@entryType", "value": SHARED_CACHE_ENTRY_DOC_TYPE}, + {"name": "@conversationVersionPrefix", "value": CONVERSATION_CACHE_VERSION_DOC_PREFIX}, + {"name": "@conversationNamespace", "value": CONVERSATION_CACHE_NAMESPACE}, + {"name": "@chatBootstrapNamespace", "value": CHAT_BOOTSTRAP_NAMESPACE}, + {"name": "@conversationEntryPrefix", "value": CONVERSATION_CACHE_ENTRY_PREFIX}, + {"name": "@chatBootstrapEntryPrefix", "value": CHAT_BOOTSTRAP_ENTRY_PREFIX}, + {"name": "@nowIso", "value": now_iso}, + ] + return list(container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + )) + + +def build_stale_cache_cleanup_status(last_result=None): + """Return a JSON-safe stale cleanup status without querying Cosmos.""" + if isinstance(last_result, dict): + result = copy.deepcopy(last_result) + result.setdefault("success", result.get("failed_count", 0) == 0) + result.setdefault("status", "completed" if result.get("success") else "completed_with_errors") + result.setdefault("mode", "apply" if result.get("apply_requested") else "dry_run") + result.setdefault("categories", _build_category_summary(_empty_category_counts())) + return result + + return { + "success": True, + "status": "not_run", + "mode": "not_run", + "apply_requested": False, + "definition_version": COSMOS_STALE_CACHE_CLEANUP_DEFINITION_VERSION, + "candidate_count": 0, + "deleted_count": 0, + "skipped_count": 0, + "failed_count": 0, + "has_more_candidates": False, + "categories": _build_category_summary(_empty_category_counts()), + "evaluated_at": None, + } + + +def build_skipped_stale_cache_cleanup_result(reason="not_requested"): + """Return a skipped cleanup step result for maintenance runs that should not scan Cosmos.""" + result = build_stale_cache_cleanup_status() + result.update({ + "status": "skipped_disabled", + "mode": "skipped", + "skip_reason": reason, + "evaluated_at": _utc_now_iso(), + }) + return result + + +def run_stale_cache_document_cleanup(apply_changes=False, batch_size=None, container=None): + """Report or delete allowlisted stale operational cache artifacts from settings.""" + cleanup_container = container or cosmos_settings_container + now = _utc_now() + now_iso = now.isoformat() + safe_batch_size = _normalize_batch_size(batch_size) + category_counts = _empty_category_counts() + candidate_count = 0 + deleted_count = 0 + skipped_count = 0 + failed_count = 0 + + try: + candidate_documents = _query_stale_cache_cleanup_candidates( + cleanup_container, + safe_batch_size, + now_iso, + ) + except Exception as exc: + log_event( + "[CosmosStaleCleanup] Failed to query stale cache cleanup candidates.", + extra={"error": str(exc), "batch_size": safe_batch_size}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return { + "success": False, + "status": "failed", + "mode": "apply" if apply_changes else "dry_run", + "apply_requested": bool(apply_changes), + "definition_version": COSMOS_STALE_CACHE_CLEANUP_DEFINITION_VERSION, + "batch_size": safe_batch_size, + "candidate_count": 0, + "deleted_count": 0, + "skipped_count": 0, + "failed_count": 1, + "has_more_candidates": False, + "categories": _build_category_summary(category_counts), + "evaluated_at": now_iso, + "error": str(exc), + } + + for document in candidate_documents: + category_id = _classify_cleanup_candidate(document, now) + if not category_id: + skipped_count += 1 + continue + + candidate_count += 1 + _increment_category_count(category_counts, category_id, "candidate_count") + document_id = str(document.get("id") or "").strip() + if not document_id: + skipped_count += 1 + _increment_category_count(category_counts, category_id, "skipped_count") + continue + + if not apply_changes: + continue + + try: + cleanup_container.delete_item(item=document_id, partition_key=document_id) + deleted_count += 1 + _increment_category_count(category_counts, category_id, "deleted_count") + except Exception as exc: + if _is_not_found_error(exc): + skipped_count += 1 + _increment_category_count(category_counts, category_id, "skipped_count") + continue + failed_count += 1 + _increment_category_count(category_counts, category_id, "failed_count") + log_event( + "[CosmosStaleCleanup] Failed to delete stale cache document.", + extra={ + "category": category_id, + "document_id_hash": _hash_document_id(document_id), + "error": str(exc), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + + status = "completed" + if failed_count: + status = "completed_with_errors" + elif not apply_changes: + status = "dry_run_completed" + + result = { + "success": failed_count == 0, + "status": status, + "mode": "apply" if apply_changes else "dry_run", + "apply_requested": bool(apply_changes), + "definition_version": COSMOS_STALE_CACHE_CLEANUP_DEFINITION_VERSION, + "batch_size": safe_batch_size, + "candidate_count": candidate_count, + "deleted_count": deleted_count, + "skipped_count": skipped_count, + "failed_count": failed_count, + "has_more_candidates": len(candidate_documents) >= safe_batch_size, + "categories": _build_category_summary(category_counts), + "evaluated_at": now_iso, + } + log_event( + "[CosmosStaleCleanup] Stale cache cleanup completed.", + extra={ + "mode": result["mode"], + "candidate_count": candidate_count, + "deleted_count": deleted_count, + "failed_count": failed_count, + "has_more_candidates": result["has_more_candidates"], + }, + level=logging.INFO if result["success"] else logging.WARNING, + ) + return result diff --git a/application/single_app/functions_cosmos_throughput.py b/application/single_app/functions_cosmos_throughput.py index d6bd7151..cd1f146e 100644 --- a/application/single_app/functions_cosmos_throughput.py +++ b/application/single_app/functions_cosmos_throughput.py @@ -2280,11 +2280,15 @@ def evaluate_and_apply_cosmos_throughput_scaling(settings, current_time=None, re scale_result['direction'] = decision.get('direction') scale_result['reason'] = decision.get('reason') + settings_update = None + if scale_result: + settings_update = build_runtime_update(status, decision, scale_result, settings=settings) + return { 'status': status, 'decision': decision, 'scale_result': scale_result, - 'settings_update': build_runtime_update(status, decision, scale_result, settings=settings), + 'settings_update': settings_update, } except Exception as exc: log_event( diff --git a/application/single_app/functions_custom_pages.py b/application/single_app/functions_custom_pages.py index 7dc94af2..07a6fcbc 100644 --- a/application/single_app/functions_custom_pages.py +++ b/application/single_app/functions_custom_pages.py @@ -3,6 +3,9 @@ import importlib.util import inspect +import copy +import hashlib +import json import logging import os import re @@ -14,7 +17,15 @@ from config import cosmos_custom_pages_container from custom_page_extension import CustomPageExtension +import app_settings_cache from functions_appinsights import log_event +from functions_shared_cache import ( + bump_shared_cache_version, + delete_shared_cache_entry, + get_shared_cache_entry, + get_shared_cache_version, + set_shared_cache_entry, +) CUSTOM_PAGES_DIR = os.path.join(os.path.dirname(__file__), "custom_pages") @@ -35,6 +46,63 @@ } LIST_FIELDS = ("roles", "css_files", "js_files", "asset_files", "json_files") PYTHON_EXTENSION_CACHE: Optional[Dict[str, Dict[str, Any]]] = None +CUSTOM_PAGES_CACHE_NAMESPACE = "custom_pages" +CUSTOM_PAGES_CACHE_VERSION_DOC_ID = "custom_pages_cache_version" +CUSTOM_PAGES_CATALOG_CACHE_KEY = "catalog" +CUSTOM_PAGES_NAV_CACHE_KEY_PREFIX = "nav" +CUSTOM_PAGES_CATALOG_CACHE_TTL_SECONDS = 300 +CUSTOM_PAGES_NAV_CACHE_TTL_SECONDS = 60 + + +def _get_shared_cache_redis_client(): + try: + return app_settings_cache.get_app_cache_redis_client() + except Exception as ex: + log_event( + "[CustomPages] Redis client lookup failed; using Cosmos cache fallback.", + extra={"error": str(ex)}, + level=logging.WARNING, + ) + return None + + +def _get_custom_pages_cache_version() -> int: + return get_shared_cache_version(CUSTOM_PAGES_CACHE_VERSION_DOC_ID, default_version=0) + + +def _stable_hash(payload: Any) -> str: + serialized = json.dumps(payload, sort_keys=True, default=str, separators=(",", ":")) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:16] + + +def invalidate_custom_pages_cache(reason: str = "custom_pages_changed") -> Optional[int]: + """Invalidate custom page catalog and navigation caches without failing callers.""" + try: + version = bump_shared_cache_version( + CUSTOM_PAGES_CACHE_VERSION_DOC_ID, + description="Custom pages catalog and navigation cache version.", + ) + except Exception as ex: + version = None + log_event( + "[CustomPages] Failed to bump custom pages cache version.", + extra={"reason": reason, "error": str(ex)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + + delete_shared_cache_entry( + CUSTOM_PAGES_CACHE_NAMESPACE, + CUSTOM_PAGES_CATALOG_CACHE_KEY, + redis_client=_get_shared_cache_redis_client(), + ) + log_event( + "[CustomPages] Custom pages cache invalidated.", + extra={"reason": reason, "version": version}, + level=logging.INFO, + debug_only=True, + ) + return version def is_custom_pages_enabled(settings: Optional[Dict[str, Any]] = None) -> bool: @@ -147,7 +215,7 @@ def list_custom_pages(include_python: bool = True) -> List[Dict[str, Any]]: return sorted(pages.values(), key=lambda item: (item.get("nav_order", 100), item.get("nav_label", "").lower())) -def list_cosmos_custom_pages() -> List[Dict[str, Any]]: +def _list_cosmos_custom_pages_source() -> List[Dict[str, Any]]: """Return static custom page metadata stored in Cosmos DB.""" try: items = list(cosmos_custom_pages_container.query_items( @@ -167,7 +235,45 @@ def list_cosmos_custom_pages() -> List[Dict[str, Any]]: return [] -def get_custom_page(slug: str, include_python: bool = True) -> Optional[Dict[str, Any]]: +def list_cosmos_custom_pages() -> List[Dict[str, Any]]: + """Return cached static custom page metadata stored in Cosmos DB.""" + version = _get_custom_pages_cache_version() + redis_client = _get_shared_cache_redis_client() + cached = get_shared_cache_entry( + CUSTOM_PAGES_CACHE_NAMESPACE, + CUSTOM_PAGES_CATALOG_CACHE_KEY, + redis_client=redis_client, + ) + if isinstance(cached, dict) and cached.get("version") == version and isinstance(cached.get("pages"), list): + log_event( + "[CustomPages] Custom page catalog cache hit.", + extra={"version": version, "count": len(cached.get("pages") or [])}, + level=logging.INFO, + debug_only=True, + ) + return copy.deepcopy(cached.get("pages") or []) + + pages = _list_cosmos_custom_pages_source() + set_shared_cache_entry( + CUSTOM_PAGES_CACHE_NAMESPACE, + CUSTOM_PAGES_CATALOG_CACHE_KEY, + { + "version": version, + "pages": pages, + }, + ttl_seconds=CUSTOM_PAGES_CATALOG_CACHE_TTL_SECONDS, + redis_client=redis_client, + ) + log_event( + "[CustomPages] Custom page catalog cache miss.", + extra={"version": version, "count": len(pages)}, + level=logging.INFO, + debug_only=True, + ) + return pages + + +def get_custom_page(slug: str, include_python: bool = True, use_cache: bool = True) -> Optional[Dict[str, Any]]: """Return a custom page by slug from Cosmos or Python discovery.""" if not is_safe_slug(slug): return None @@ -178,6 +284,12 @@ def get_custom_page(slug: str, include_python: bool = True) -> Optional[Dict[str if normalized_slug in python_pages: return python_pages[normalized_slug] + if use_cache: + for page in list_cosmos_custom_pages(): + if page.get("slug") == normalized_slug: + return page + return None + try: item = cosmos_custom_pages_container.read_item(item=normalized_slug, partition_key=normalized_slug) return normalize_custom_page_metadata(_strip_cosmos_fields(item), source=CUSTOM_PAGE_SOURCE_COSMOS) @@ -202,12 +314,13 @@ def save_custom_page(metadata: Dict[str, Any], user_id: str = "system") -> Dict[ raise ValueError("; ".join(errors)) now = datetime.utcnow().isoformat() - existing = get_custom_page(page["slug"], include_python=False) + existing = get_custom_page(page["slug"], include_python=False, use_cache=False) page["created_by"] = existing.get("created_by", user_id) if existing else user_id page["created_at"] = existing.get("created_at", now) if existing else now page["modified_by"] = user_id page["modified_at"] = now result = cosmos_custom_pages_container.upsert_item(body=page) + invalidate_custom_pages_cache(reason="save_custom_page") return normalize_custom_page_metadata(_strip_cosmos_fields(result), source=CUSTOM_PAGE_SOURCE_COSMOS) @@ -217,6 +330,7 @@ def delete_custom_page(slug: str) -> bool: return False try: cosmos_custom_pages_container.delete_item(item=slug, partition_key=slug) + invalidate_custom_pages_cache(reason="delete_custom_page") return True except exceptions.CosmosResourceNotFoundError: return False @@ -280,8 +394,8 @@ def discover_python_custom_pages(force_refresh: bool = False) -> Dict[str, Dict[ return discovered -def get_custom_pages_nav(settings: Dict[str, Any]) -> List[Dict[str, Any]]: - """Return authorized custom page navigation items for the current request.""" +def _build_custom_pages_nav(settings: Dict[str, Any]) -> List[Dict[str, Any]]: + """Build authorized custom page navigation items for the current request.""" if not is_custom_pages_enabled(settings): return [] @@ -309,6 +423,53 @@ def get_custom_pages_nav(settings: Dict[str, Any]) -> List[Dict[str, Any]]: return sorted(nav_items, key=lambda item: (item.get("order", 100), item.get("label", "").lower())) +def get_custom_pages_nav(settings: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return cached authorized custom page navigation items for the current request.""" + if not is_custom_pages_enabled(settings): + return [] + + roles = _current_user_roles() + version = _get_custom_pages_cache_version() + cache_key = ( + f"{CUSTOM_PAGES_NAV_CACHE_KEY_PREFIX}:" + f"{_stable_hash({'roles': sorted(roles), 'enabled': is_custom_pages_enabled(settings)})}" + ) + redis_client = _get_shared_cache_redis_client() + cached = get_shared_cache_entry( + CUSTOM_PAGES_CACHE_NAMESPACE, + cache_key, + redis_client=redis_client, + ) + if isinstance(cached, dict) and cached.get("version") == version and isinstance(cached.get("nav"), list): + log_event( + "[CustomPages] Custom page navigation cache hit.", + extra={"version": version, "nav_count": len(cached.get("nav") or [])}, + level=logging.INFO, + debug_only=True, + ) + return copy.deepcopy(cached.get("nav") or []) + + nav_items = _build_custom_pages_nav(settings) + ttl_seconds = int(settings.get("custom_pages_nav_cache_ttl_seconds") or CUSTOM_PAGES_NAV_CACHE_TTL_SECONDS) + set_shared_cache_entry( + CUSTOM_PAGES_CACHE_NAMESPACE, + cache_key, + { + "version": version, + "nav": nav_items, + }, + ttl_seconds=ttl_seconds, + redis_client=redis_client, + ) + log_event( + "[CustomPages] Custom page navigation cache miss.", + extra={"version": version, "nav_count": len(nav_items)}, + level=logging.INFO, + debug_only=True, + ) + return nav_items + + def is_custom_page_authorized(page: Dict[str, Any], roles: Optional[List[str]] = None) -> bool: """Return True when the current user roles satisfy custom page metadata.""" role_set = set(roles if roles is not None else _current_user_roles()) diff --git a/application/single_app/functions_data_management.py b/application/single_app/functions_data_management.py index 83fcb8b0..4938f2c2 100644 --- a/application/single_app/functions_data_management.py +++ b/application/single_app/functions_data_management.py @@ -12,7 +12,8 @@ from azure.core import MatchConditions from azure.core.credentials import AzureKeyCredential -from azure.cosmos import CosmosClient, PartitionKey +import azure.cosmos as azure_cosmos +from azure.cosmos import PartitionKey from azure.cosmos.exceptions import CosmosResourceNotFoundError from azure.identity import DefaultAzureCredential from azure.search.documents import SearchClient @@ -713,9 +714,9 @@ def _get_target_cosmos_database(settings): key = _safe_text((settings or {}).get("target_cosmos_key")) if not key: raise ValueError("Target Cosmos account key is required when account key authentication is selected.") - client = CosmosClient(endpoint, credential=key, consistency_level="Session") + client = azure_cosmos.CosmosClient(endpoint, credential=key, consistency_level="Session") else: - client = CosmosClient(endpoint, credential=DefaultAzureCredential(), consistency_level="Session") + client = azure_cosmos.CosmosClient(endpoint, credential=DefaultAzureCredential(), consistency_level="Session") return client.create_database_if_not_exists(DATA_MANAGEMENT_TARGET_COSMOS_DATABASE_NAME) diff --git a/application/single_app/functions_document_access_index.py b/application/single_app/functions_document_access_index.py new file mode 100644 index 00000000..a34250e1 --- /dev/null +++ b/application/single_app/functions_document_access_index.py @@ -0,0 +1,4309 @@ +# functions_document_access_index.py +"""Document access index projection helpers for Cosmos-backed document reads.""" + +import copy +import hashlib +import json +import logging +import re +import threading +import uuid +from datetime import datetime, timedelta, timezone +from time import perf_counter + +import app_settings_cache +from azure.core import MatchConditions +from config import ( + cosmos_document_access_index_container, + cosmos_groups_container, + cosmos_group_documents_container, + cosmos_group_documents_container_name, + cosmos_public_workspaces_container, + cosmos_public_documents_container, + cosmos_public_documents_container_name, + cosmos_settings_container, + cosmos_user_settings_container, + cosmos_user_documents_container, + cosmos_user_documents_container_name, +) +from functions_appinsights import log_event +from functions_settings import get_settings + + +DOCUMENT_ACCESS_INDEX_TYPE = 'document_access_index' +DOCUMENT_ACCESS_REPAIR_TYPE = 'document_access_index_repair' +DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_TYPE = 'document_access_index_repair_backlog_state' +DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID = 'document_access_index_repair_backlog_state' +DOCUMENT_ACCESS_BACKFILL_STATE_TYPE = 'document_access_index_backfill_state' +DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID = 'document_access_index_backfill_state' +DOCUMENT_ACCESS_SHADOW_STATE_TYPE = 'document_access_index_shadow_validation_state' +DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID = 'document_access_index_shadow_validation_state' +DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION = 2 + +DOCUMENT_ACCESS_SCOPE_PERSONAL = 'personal' +DOCUMENT_ACCESS_SCOPE_GROUP = 'group' +DOCUMENT_ACCESS_SCOPE_PUBLIC = 'public' +DOCUMENT_ACCESS_PRINCIPAL_USER = 'user' + +DOCUMENT_ACCESS_OPERATION_UPSERT = 'upsert' +DOCUMENT_ACCESS_OPERATION_DELETE = 'delete' + +DOCUMENT_ACCESS_APPROVAL_APPROVED = 'approved' +DOCUMENT_ACCESS_APPROVAL_NOT_APPROVED = 'not_approved' + +DOCUMENT_ACCESS_BACKFILL_ENABLED_SETTING = 'enable_startup_document_access_index_backfill' +DOCUMENT_ACCESS_BACKFILL_BATCH_SIZE_SETTING = 'document_access_index_backfill_batch_size' +DOCUMENT_ACCESS_REPAIR_BATCH_SIZE_SETTING = 'document_access_index_repair_batch_size' +DOCUMENT_ACCESS_ACTIVE_MAINTENANCE_INTERVAL_SETTING = 'document_access_index_active_maintenance_interval_seconds' +DOCUMENT_ACCESS_CACHE_ENABLED_SETTING = 'enable_document_access_index_cache' +DOCUMENT_ACCESS_CACHE_TTL_SECONDS_SETTING = 'document_access_index_cache_ttl_seconds' +DOCUMENT_ACCESS_DEFAULT_BACKFILL_BATCH_SIZE = 200 +DOCUMENT_ACCESS_MAX_BACKFILL_BATCH_SIZE = 1000 +DOCUMENT_ACCESS_DEFAULT_REPAIR_BATCH_SIZE = 100 +DOCUMENT_ACCESS_MAX_REPAIR_BATCH_SIZE = 500 +DOCUMENT_ACCESS_DEFAULT_CACHE_TTL_SECONDS = 900 +DOCUMENT_ACCESS_MIN_CACHE_TTL_SECONDS = 60 +DOCUMENT_ACCESS_MAX_CACHE_TTL_SECONDS = 900 +DOCUMENT_ACCESS_CACHE_VERSION_MIN_TTL_SECONDS = 3600 +DOCUMENT_ACCESS_CACHE_VERSION_MAX_TTL_SECONDS = 86400 +DOCUMENT_ACCESS_CACHE_VERSION_TTL_MULTIPLIER = 4 +DOCUMENT_ACCESS_CACHE_VERSION_HYGIENE_BATCH_SIZE = 100 +DOCUMENT_ACCESS_CACHE_VERSION_HYGIENE_MAX_SCAN_ITERATIONS = 5 +DOCUMENT_ACCESS_CACHE_KEY_PREFIX = 'DAI_LIST_CACHE' +DOCUMENT_ACCESS_CACHE_VERSION_KEY_PREFIX = 'DAI_LIST_CACHE_VERSION' +DOCUMENT_ACCESS_ARCHIVED_REVISION_BLOB_PATH_MODE = 'archived_revision' +DOCUMENT_ACCESS_SHADOW_MAX_SAMPLE_IDS = 20 +DOCUMENT_ACCESS_SHADOW_METRIC_WINDOWS_MINUTES = (5, 15) +DOCUMENT_ACCESS_SHADOW_METRIC_RETENTION_MINUTES = 60 +DOCUMENT_ACCESS_SHADOW_METRIC_MAX_SAMPLES = 1000 +DOCUMENT_ACCESS_SHADOW_STATE_WRITE_MAX_RETRIES = 5 +DOCUMENT_ACCESS_READ_METRIC_WINDOWS_MINUTES = (5, 15, 60) +DOCUMENT_ACCESS_READ_METRIC_RETENTION_MINUTES = 60 +DOCUMENT_ACCESS_READ_METRIC_MAX_SAMPLES = 2000 +DOCUMENT_ACCESS_CACHE_METRIC_WINDOWS_MINUTES = (5, 15, 60) +DOCUMENT_ACCESS_CACHE_METRIC_RETENTION_MINUTES = 60 +DOCUMENT_ACCESS_CACHE_METRIC_MAX_SAMPLES = 2000 +DOCUMENT_ACCESS_STATE_READ_TTL_SECONDS = 30 +DOCUMENT_ACCESS_BACKFILL_READY_STATUSES = {'succeeded', 'succeeded_with_errors'} +DOCUMENT_ACCESS_BACKFILL_COMPLETE_STATUSES = DOCUMENT_ACCESS_BACKFILL_READY_STATUSES | {'skipped_completed'} + +DOCUMENT_ACCESS_SOURCE_SCOPES = ( + DOCUMENT_ACCESS_SCOPE_PERSONAL, + DOCUMENT_ACCESS_SCOPE_GROUP, + DOCUMENT_ACCESS_SCOPE_PUBLIC, +) + +_document_access_read_metric_lock = threading.Lock() +_document_access_read_metric_samples = [] +_document_access_cache_metric_lock = threading.Lock() +_document_access_state_cache = {} +_document_access_state_cache_lock = threading.Lock() +_document_access_cache_metric_samples = [] +_document_access_cache_epoch_lock = threading.Lock() +_document_access_cache_process_epoch = uuid.uuid4().hex +_document_access_cache_local_generation = 0 + + +class DocumentAccessIndexCacheInvalidationError(RuntimeError): + """Raised when DAI cache scope invalidation fails after projection mutation.""" + + def __init__(self, operation, status, scope_keys=None): + super().__init__( + 'Document access index cache invalidation failed ' + f'for {operation}: {status or "unknown"}' + ) + self.operation = operation + self.status = status + self.scope_keys = list(scope_keys or []) + + +class DocumentAccessIndexProjectionMutationError(RuntimeError): + """Raised when projection mutation fails after affected cache scopes are known.""" + + def __init__(self, operation, error, scope_keys=None): + super().__init__(str(error)) + self.operation = operation + self.original_error = error + self.scope_keys = list(scope_keys or []) + + +def _utc_now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def _parse_utc_datetime(value): + if not value: + return None + try: + parsed_value = datetime.fromisoformat(str(value).replace('Z', '+00:00')) + except ValueError: + return None + if parsed_value.tzinfo is None: + parsed_value = parsed_value.replace(tzinfo=timezone.utc) + return parsed_value.astimezone(timezone.utc) + + +def _get_cached_document_access_state(doc_id): + now = perf_counter() + with _document_access_state_cache_lock: + cached_state = _document_access_state_cache.get(doc_id) + if not cached_state: + return False, None + if cached_state.get('expires_at', 0) <= now: + _document_access_state_cache.pop(doc_id, None) + return False, None + return True, copy.deepcopy(cached_state.get('value')) + + +def _set_cached_document_access_state(doc_id, value): + with _document_access_state_cache_lock: + _document_access_state_cache[doc_id] = { + 'value': copy.deepcopy(value), + 'expires_at': perf_counter() + DOCUMENT_ACCESS_STATE_READ_TTL_SECONDS, + } + + +def _normalize_positive_int(value, default_value, min_value=1, max_value=1000): + try: + normalized_value = int(value) + except (TypeError, ValueError): + normalized_value = default_value + return min(max(normalized_value, min_value), max_value) + + +def _calculate_document_access_cache_version_ttl_seconds(cache_ttl_seconds): + normalized_cache_ttl = _normalize_positive_int( + cache_ttl_seconds, + DOCUMENT_ACCESS_DEFAULT_CACHE_TTL_SECONDS, + min_value=DOCUMENT_ACCESS_MIN_CACHE_TTL_SECONDS, + max_value=DOCUMENT_ACCESS_MAX_CACHE_TTL_SECONDS, + ) + return min( + max( + normalized_cache_ttl * DOCUMENT_ACCESS_CACHE_VERSION_TTL_MULTIPLIER, + DOCUMENT_ACCESS_CACHE_VERSION_MIN_TTL_SECONDS, + ), + DOCUMENT_ACCESS_CACHE_VERSION_MAX_TTL_SECONDS, + ) + + +def _safe_float(value): + if value in (None, ''): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _round_metric(value, digits=3): + normalized_value = _safe_float(value) + if normalized_value is None: + return None + return round(normalized_value, digits) + + +def _sum_metric_values(items, key): + values = [ + _safe_float(item.get(key)) + for item in items or [] + if _safe_float(item.get(key)) is not None + ] + if not values: + return None + return _round_metric(sum(values)) + + +def _average_metric_values(items, key): + values = [ + _safe_float(item.get(key)) + for item in items or [] + if _safe_float(item.get(key)) is not None + ] + if not values: + return None + return _round_metric(sum(values) / len(values)) + + +def _sum_int_values(items, key): + total = 0 + for item in items or []: + try: + total += int(item.get(key) or 0) + except (TypeError, ValueError): + continue + return total + + +def _safe_int(value, default_value=0): + try: + return int(value or default_value) + except (TypeError, ValueError): + return default_value + + +def _get_header_value(headers, name): + if not hasattr(headers, 'get'): + return None + return headers.get(name) or headers.get(name.lower()) or headers.get(name.upper()) + + +def _metric_delta(source_value, projection_value): + source_metric = _safe_float(source_value) + projection_metric = _safe_float(projection_value) + if source_metric is None or projection_metric is None: + return None + return _round_metric(source_metric - projection_metric) + + +def _metric_percent(numerator, denominator): + try: + denominator_value = float(denominator or 0) + if denominator_value <= 0: + return None + return _round_metric((float(numerator or 0) / denominator_value) * 100) + except (TypeError, ValueError): + return None + + +def _percentile_metric_value(items, key, percentile): + values = sorted( + _safe_float(item.get(key)) + for item in items or [] + if _safe_float(item.get(key)) is not None + ) + if not values: + return None + index = int(round((len(values) - 1) * percentile)) + return _round_metric(values[min(max(index, 0), len(values) - 1)]) + + +def _empty_read_metric_window(window_minutes): + return { + 'window_minutes': window_minutes, + 'sample_count': 0, + 'served_from_index_count': 0, + 'served_from_cache_count': 0, + 'source_fallback_count': 0, + 'query_failed_count': 0, + 'fallback_rate_percent': None, + 'cache_hit_rate_percent': None, + 'request_charge': None, + 'elapsed_ms_avg': None, + 'elapsed_ms_p95': None, + 'item_count': 0, + 'page_count': 0, + 'operation_counts': {}, + 'status_counts': {}, + 'first_checked_at': None, + 'last_checked_at': None, + } + + +def _prune_read_metric_samples(samples, now): + cutoff = now - timedelta(minutes=DOCUMENT_ACCESS_READ_METRIC_RETENTION_MINUTES) + pruned_samples = [] + for sample in samples or []: + if not isinstance(sample, dict): + continue + checked_at = _parse_utc_datetime(sample.get('checked_at')) + if not checked_at or checked_at < cutoff: + continue + pruned_samples.append(copy.deepcopy(sample)) + + pruned_samples.sort(key=lambda sample: sample.get('checked_at') or '') + return pruned_samples[-DOCUMENT_ACCESS_READ_METRIC_MAX_SAMPLES:] + + +def _increment_count(counts, key): + normalized_key = str(key or 'unknown').strip().lower() or 'unknown' + counts[normalized_key] = int(counts.get(normalized_key) or 0) + 1 + + +def _build_read_metric_window(samples, now, window_minutes): + cutoff = now - timedelta(minutes=window_minutes) + window_samples = [ + sample + for sample in samples or [] + if (_parse_utc_datetime(sample.get('checked_at')) or datetime.min.replace(tzinfo=timezone.utc)) >= cutoff + ] + if not window_samples: + return _empty_read_metric_window(window_minutes) + + served_samples = [ + sample + for sample in window_samples + if sample.get('served_from_index') + ] + operation_counts = {} + status_counts = {} + for sample in window_samples: + _increment_count(operation_counts, sample.get('operation')) + _increment_count(status_counts, sample.get('status')) + + source_fallback_count = sum(1 for sample in window_samples if sample.get('source_fallback')) + served_from_cache_count = sum(1 for sample in served_samples if sample.get('served_from_cache')) + return { + 'window_minutes': window_minutes, + 'sample_count': len(window_samples), + 'served_from_index_count': len(served_samples), + 'served_from_cache_count': served_from_cache_count, + 'source_fallback_count': source_fallback_count, + 'query_failed_count': sum(1 for sample in window_samples if sample.get('status') == 'query_failed'), + 'fallback_rate_percent': _metric_percent(source_fallback_count, len(window_samples)), + 'cache_hit_rate_percent': _metric_percent(served_from_cache_count, len(served_samples)), + 'request_charge': _sum_metric_values(served_samples, 'request_charge'), + 'elapsed_ms_avg': _average_metric_values(served_samples, 'elapsed_ms'), + 'elapsed_ms_p95': _percentile_metric_value(served_samples, 'elapsed_ms', 0.95), + 'item_count': _sum_int_values(served_samples, 'item_count'), + 'page_count': _sum_int_values(served_samples, 'page_count'), + 'operation_counts': operation_counts, + 'status_counts': status_counts, + 'first_checked_at': window_samples[0].get('checked_at'), + 'last_checked_at': window_samples[-1].get('checked_at'), + } + + +def _build_read_metrics(samples, now): + fallback_samples = [ + sample + for sample in samples or [] + if sample.get('source_fallback') + ] + return { + 'updated_at': now.isoformat(), + 'retention_minutes': DOCUMENT_ACCESS_READ_METRIC_RETENTION_MINUTES, + 'sample_limit': DOCUMENT_ACCESS_READ_METRIC_MAX_SAMPLES, + 'sample_count': len(samples or []), + 'last_sample': copy.deepcopy(samples[-1]) if samples else None, + 'last_fallback_sample': copy.deepcopy(fallback_samples[-1]) if fallback_samples else None, + 'windows': { + f'{window_minutes}m': _build_read_metric_window(samples, now, window_minutes) + for window_minutes in DOCUMENT_ACCESS_READ_METRIC_WINDOWS_MINUTES + }, + } + + +def _record_document_access_read_metric( + operation, + source_scope, + status, + served_from_index, + diagnostics=None, + scope_key_count=0, + served_from_cache=False, + cache_status=None, + error=None, +): + diagnostics = diagnostics if isinstance(diagnostics, dict) else {} + sample = { + 'checked_at': _utc_now_iso(), + 'operation': str(operation or 'document_list').strip().lower(), + 'source_scope': str(source_scope or '').strip().lower(), + 'status': str(status or 'unknown').strip().lower() or 'unknown', + 'served_from_index': bool(served_from_index), + 'served_from_cache': bool(served_from_cache), + 'source_fallback': not bool(served_from_index), + 'cache_status': str(cache_status or '').strip().lower() or None, + 'request_charge': _round_metric(diagnostics.get('request_charge')), + 'elapsed_ms': _round_metric(diagnostics.get('elapsed_ms')), + 'item_count': _safe_int(diagnostics.get('item_count')), + 'page_count': _safe_int(diagnostics.get('page_count')), + 'scope_key_count': _safe_int(scope_key_count), + } + if error: + sample['error'] = str(error) + + with _document_access_read_metric_lock: + now = datetime.now(timezone.utc) + samples = _prune_read_metric_samples(_document_access_read_metric_samples, now) + samples.append(sample) + del samples[:-DOCUMENT_ACCESS_READ_METRIC_MAX_SAMPLES] + _document_access_read_metric_samples[:] = samples + return sample + + +def get_document_access_index_read_metrics(): + """Return lightweight in-process rolling metrics for production DAI reads.""" + with _document_access_read_metric_lock: + samples = copy.deepcopy(_document_access_read_metric_samples) + now = datetime.now(timezone.utc) + samples = _prune_read_metric_samples(samples, now) + return _build_read_metrics(samples, now) + + +def _empty_cache_metric_window(window_minutes): + return { + 'window_minutes': window_minutes, + 'sample_count': 0, + 'hit_count': 0, + 'miss_count': 0, + 'bypass_count': 0, + 'write_count': 0, + 'invalidation_count': 0, + 'error_count': 0, + 'hit_rate_percent': None, + 'operation_counts': {}, + 'event_counts': {}, + 'reason_counts': {}, + 'first_checked_at': None, + 'last_checked_at': None, + } + + +def _prune_cache_metric_samples(samples, now): + cutoff = now - timedelta(minutes=DOCUMENT_ACCESS_CACHE_METRIC_RETENTION_MINUTES) + pruned_samples = [] + for sample in samples or []: + if not isinstance(sample, dict): + continue + checked_at = _parse_utc_datetime(sample.get('checked_at')) + if not checked_at or checked_at < cutoff: + continue + pruned_samples.append(copy.deepcopy(sample)) + + pruned_samples.sort(key=lambda sample: sample.get('checked_at') or '') + return pruned_samples[-DOCUMENT_ACCESS_CACHE_METRIC_MAX_SAMPLES:] + + +def _cache_metric_matches(sample, event_types): + return str(sample.get('event_type') or '').strip().lower() in event_types + + +def _build_cache_metric_window(samples, now, window_minutes): + cutoff = now - timedelta(minutes=window_minutes) + window_samples = [ + sample + for sample in samples or [] + if (_parse_utc_datetime(sample.get('checked_at')) or datetime.min.replace(tzinfo=timezone.utc)) >= cutoff + ] + if not window_samples: + return _empty_cache_metric_window(window_minutes) + + operation_counts = {} + event_counts = {} + reason_counts = {} + for sample in window_samples: + _increment_count(operation_counts, sample.get('operation')) + _increment_count(event_counts, sample.get('event_type')) + _increment_count(reason_counts, sample.get('reason')) + + hit_count = sum(1 for sample in window_samples if _cache_metric_matches(sample, {'hit'})) + miss_count = sum(1 for sample in window_samples if _cache_metric_matches(sample, {'miss'})) + bypass_count = sum(1 for sample in window_samples if _cache_metric_matches(sample, {'bypass'})) + write_count = sum(1 for sample in window_samples if _cache_metric_matches(sample, {'write'})) + invalidation_count = sum(1 for sample in window_samples if _cache_metric_matches(sample, {'invalidate'})) + error_count = sum( + 1 + for sample in window_samples + if _cache_metric_matches(sample, {'read_failed', 'write_failed', 'version_read_failed', 'invalidate_failed'}) + ) + return { + 'window_minutes': window_minutes, + 'sample_count': len(window_samples), + 'hit_count': hit_count, + 'miss_count': miss_count, + 'bypass_count': bypass_count, + 'write_count': write_count, + 'invalidation_count': invalidation_count, + 'error_count': error_count, + 'hit_rate_percent': _metric_percent(hit_count, hit_count + miss_count), + 'operation_counts': operation_counts, + 'event_counts': event_counts, + 'reason_counts': reason_counts, + 'first_checked_at': window_samples[0].get('checked_at'), + 'last_checked_at': window_samples[-1].get('checked_at'), + } + + +def _build_cache_metrics(samples, now): + error_samples = [ + sample + for sample in samples or [] + if str(sample.get('event_type') or '').strip().lower().endswith('_failed') + ] + invalidation_samples = [ + sample + for sample in samples or [] + if str(sample.get('event_type') or '').strip().lower() == 'invalidate' + ] + return { + 'updated_at': now.isoformat(), + 'retention_minutes': DOCUMENT_ACCESS_CACHE_METRIC_RETENTION_MINUTES, + 'sample_limit': DOCUMENT_ACCESS_CACHE_METRIC_MAX_SAMPLES, + 'sample_count': len(samples or []), + 'last_event': copy.deepcopy(samples[-1]) if samples else None, + 'last_error': copy.deepcopy(error_samples[-1]) if error_samples else None, + 'last_invalidation': copy.deepcopy(invalidation_samples[-1]) if invalidation_samples else None, + 'windows': { + f'{window_minutes}m': _build_cache_metric_window(samples, now, window_minutes) + for window_minutes in DOCUMENT_ACCESS_CACHE_METRIC_WINDOWS_MINUTES + }, + } + + +def _record_document_access_cache_metric( + event_type, + operation=None, + source_scope=None, + scope_key_count=0, + reason=None, + elapsed_ms=None, + ttl_seconds=None, + error=None, +): + sample = { + 'checked_at': _utc_now_iso(), + 'event_type': str(event_type or 'unknown').strip().lower() or 'unknown', + 'operation': str(operation or 'document_list').strip().lower() or 'document_list', + 'source_scope': str(source_scope or '').strip().lower(), + 'scope_key_count': _safe_int(scope_key_count), + 'reason': str(reason or '').strip().lower() or None, + 'elapsed_ms': _round_metric(elapsed_ms), + 'ttl_seconds': _safe_int(ttl_seconds), + } + if error: + sample['error_type'] = type(error).__name__ + + with _document_access_cache_metric_lock: + now = datetime.now(timezone.utc) + samples = _prune_cache_metric_samples(_document_access_cache_metric_samples, now) + samples.append(sample) + del samples[:-DOCUMENT_ACCESS_CACHE_METRIC_MAX_SAMPLES] + _document_access_cache_metric_samples[:] = samples + return sample + + +def get_document_access_index_cache_metrics(): + """Return lightweight in-process rolling metrics for Redis DAI list caching.""" + with _document_access_cache_metric_lock: + samples = copy.deepcopy(_document_access_cache_metric_samples) + now = datetime.now(timezone.utc) + samples = _prune_cache_metric_samples(samples, now) + return _build_cache_metrics(samples, now) + + +def get_document_access_index_settings(settings=None): + """Normalize document access index feature flags.""" + if settings is None: + try: + settings = get_settings() + except Exception as exc: + log_event( + '[DocumentAccessIndex] Settings could not be loaded; DAI reads will use source fallback until settings are available.', + extra={'error': str(exc)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + settings = {} + if not isinstance(settings, dict): + log_event( + '[DocumentAccessIndex] Settings payload was invalid; DAI reads will use source fallback until settings are available.', + extra={'settings_type': type(settings).__name__}, + level=logging.WARNING, + ) + settings = {} + cache_ttl_seconds = _normalize_positive_int( + settings.get(DOCUMENT_ACCESS_CACHE_TTL_SECONDS_SETTING), + DOCUMENT_ACCESS_DEFAULT_CACHE_TTL_SECONDS, + min_value=DOCUMENT_ACCESS_MIN_CACHE_TTL_SECONDS, + max_value=DOCUMENT_ACCESS_MAX_CACHE_TTL_SECONDS, + ) + return { + 'container_enabled': True, + 'write_through_enabled': True, + 'reads_enabled': True, + 'shadow_validation_enabled': bool(settings.get('enable_document_access_index_shadow_validation', False)), + 'startup_backfill_enabled': True, + 'backfill_batch_size': _normalize_positive_int( + settings.get(DOCUMENT_ACCESS_BACKFILL_BATCH_SIZE_SETTING), + DOCUMENT_ACCESS_DEFAULT_BACKFILL_BATCH_SIZE, + max_value=DOCUMENT_ACCESS_MAX_BACKFILL_BATCH_SIZE, + ), + 'repair_batch_size': _normalize_positive_int( + settings.get(DOCUMENT_ACCESS_REPAIR_BATCH_SIZE_SETTING), + DOCUMENT_ACCESS_DEFAULT_REPAIR_BATCH_SIZE, + max_value=DOCUMENT_ACCESS_MAX_REPAIR_BATCH_SIZE, + ), + 'cache_enabled': bool(settings.get(DOCUMENT_ACCESS_CACHE_ENABLED_SETTING, True)), + 'redis_cache_configured': bool(settings.get('enable_redis_cache', False)), + 'cache_ttl_seconds': cache_ttl_seconds, + 'cache_version_ttl_seconds': _calculate_document_access_cache_version_ttl_seconds(cache_ttl_seconds), + } + + +def is_document_access_shadow_validation_enabled(settings=None): + """Return True when shadow validation should collect comparison diagnostics.""" + normalized_settings = get_document_access_index_settings(settings) + return ( + bool(normalized_settings.get('container_enabled')) + and bool(normalized_settings.get('shadow_validation_enabled')) + ) + + +def is_document_access_index_reads_enabled(settings=None): + """Return True when the default DAI read path is enabled.""" + normalized_settings = get_document_access_index_settings(settings) + return ( + bool(normalized_settings.get('container_enabled')) + and bool(normalized_settings.get('write_through_enabled')) + and bool(normalized_settings.get('reads_enabled')) + ) + + +def query_items_with_cosmos_diagnostics(container, diagnostics_label, collect_diagnostics=True, **query_kwargs): + """Run a Cosmos query and optionally capture elapsed time and request charge.""" + if not collect_diagnostics: + return list(container.query_items(**query_kwargs)), None + + query_kwargs = copy.copy(query_kwargs) + existing_response_hook = query_kwargs.get('response_hook') + request_charge_total = 0.0 + has_request_charge = False + page_count = 0 + activity_ids = [] + query_metrics = [] + + def capture_response_headers(headers, response): + nonlocal request_charge_total, has_request_charge, page_count + if callable(existing_response_hook): + existing_response_hook(headers, response) + + if hasattr(response, 'by_page'): + return + + page_count += 1 + request_charge = _safe_float(_get_header_value(headers, 'x-ms-request-charge')) + if request_charge is not None: + request_charge_total += request_charge + has_request_charge = True + + activity_id = _get_header_value(headers, 'x-ms-activity-id') + if activity_id: + activity_ids.append(str(activity_id)) + + query_metric = _get_header_value(headers, 'x-ms-documentdb-query-metrics') + if query_metric: + query_metrics.append(str(query_metric)) + + query_kwargs['populate_query_metrics'] = True + query_kwargs['response_hook'] = capture_response_headers + started_at = perf_counter() + items = list(container.query_items(**query_kwargs)) + elapsed_ms = _round_metric((perf_counter() - started_at) * 1000) + diagnostics = { + 'label': diagnostics_label, + 'request_charge': _round_metric(request_charge_total) if has_request_charge else None, + 'elapsed_ms': elapsed_ms, + 'item_count': len(items), + 'page_count': page_count, + } + if activity_ids: + diagnostics['activity_ids'] = activity_ids[:5] + if query_metrics: + diagnostics['query_metrics'] = query_metrics[:5] + return items, diagnostics + + +def _combine_query_diagnostics(label, diagnostics_list): + diagnostics_list = [diagnostics for diagnostics in diagnostics_list or [] if isinstance(diagnostics, dict)] + request_charges = [ + _safe_float(diagnostics.get('request_charge')) + for diagnostics in diagnostics_list + if _safe_float(diagnostics.get('request_charge')) is not None + ] + elapsed_values = [ + _safe_float(diagnostics.get('elapsed_ms')) + for diagnostics in diagnostics_list + if _safe_float(diagnostics.get('elapsed_ms')) is not None + ] + return { + 'label': label, + 'request_charge': _round_metric(sum(request_charges)) if request_charges else None, + 'elapsed_ms': _round_metric(sum(elapsed_values)) if elapsed_values else None, + 'item_count': sum(int(diagnostics.get('item_count') or 0) for diagnostics in diagnostics_list), + 'page_count': sum(int(diagnostics.get('page_count') or 0) for diagnostics in diagnostics_list), + } + + +def _build_shadow_metric_fields(source_query_metrics, projection_query_metrics, candidate_read_metrics=None): + source_ru = _round_metric((source_query_metrics or {}).get('request_charge')) + validation_index_ru = _round_metric((projection_query_metrics or {}).get('request_charge')) + source_ms = _round_metric((source_query_metrics or {}).get('elapsed_ms')) + validation_index_ms = _round_metric((projection_query_metrics or {}).get('elapsed_ms')) + candidate_metrics_available = ( + isinstance(candidate_read_metrics, dict) + and not candidate_read_metrics.get('partial_failure') + and not candidate_read_metrics.get('errors') + ) + candidate_read_ru = ( + _round_metric(candidate_read_metrics.get('request_charge')) + if candidate_metrics_available + else None + ) + candidate_read_ms = ( + _round_metric(candidate_read_metrics.get('elapsed_ms')) + if candidate_metrics_available + else None + ) + return { + 'source_query_ru': source_ru, + 'projection_query_ru': validation_index_ru, + 'validation_index_ru': validation_index_ru, + 'candidate_read_ru': candidate_read_ru, + 'estimated_ru_savings': _metric_delta(source_ru, validation_index_ru), + 'estimated_wave5_ru_savings': _metric_delta(source_ru, candidate_read_ru), + 'shadow_overhead_ru': validation_index_ru, + 'source_query_ms': source_ms, + 'projection_query_ms': validation_index_ms, + 'validation_index_ms': validation_index_ms, + 'candidate_read_ms': candidate_read_ms, + 'estimated_ms_savings': _metric_delta(source_ms, validation_index_ms), + 'estimated_wave5_ms_savings': _metric_delta(source_ms, candidate_read_ms), + 'shadow_overhead_ms': validation_index_ms, + 'source_query_item_count': int((source_query_metrics or {}).get('item_count') or 0), + 'projection_query_item_count': int((projection_query_metrics or {}).get('item_count') or 0), + 'candidate_read_item_count': int((candidate_read_metrics or {}).get('item_count') or 0), + 'source_query_page_count': int((source_query_metrics or {}).get('page_count') or 0), + 'projection_query_page_count': int((projection_query_metrics or {}).get('page_count') or 0), + 'candidate_read_page_count': int((candidate_read_metrics or {}).get('page_count') or 0), + } + + +def _is_container_enabled(settings=None): + normalized_settings = get_document_access_index_settings(settings) + return normalized_settings.get('container_enabled') + + +def _is_write_through_enabled(settings=None, force=False): + normalized_settings = get_document_access_index_settings(settings) + if force: + return normalized_settings.get('container_enabled') + return normalized_settings.get('container_enabled') and normalized_settings.get('write_through_enabled') + + +def _is_not_found_error(exc): + return getattr(exc, 'status_code', None) == 404 + + +def _is_write_conflict_error(exc): + return getattr(exc, 'status_code', None) in (409, 412) + + +def _safe_id_part(value): + normalized = str(value or '').strip() + normalized = re.sub(r'[^A-Za-z0-9_.:-]+', '_', normalized) + return normalized or 'none' + + +def build_document_access_scope_key(scope_type, scope_id): + """Build the access-index partition key for a user, group, or public workspace scope.""" + normalized_scope_type = str(scope_type or '').strip().lower() + normalized_scope_id = str(scope_id or '').strip() + if not normalized_scope_type or not normalized_scope_id: + return None + return f'{normalized_scope_type}:{normalized_scope_id}' + + +def _document_access_cache_hash(value): + serialized_value = json.dumps(value, sort_keys=True, separators=(',', ':'), default=str) + return hashlib.sha256(serialized_value.encode('utf-8')).hexdigest() + + +def build_document_access_cache_scope_hash(scope_key): + """Return the stable Redis DAI cache hash for a user/group/public scope key.""" + return _document_access_cache_hash({'scope_key': scope_key}) + + +def _document_access_cache_version_key(scope_key): + scope_hash = build_document_access_cache_scope_hash(scope_key) + return f'{DOCUMENT_ACCESS_CACHE_VERSION_KEY_PREFIX}:{scope_hash}' + + +def _document_access_cache_entry_key(operation, source_scope, scope_versions, key_payload): + cache_hash = _document_access_cache_hash({ + 'operation': operation, + 'source_scope': source_scope, + 'schema_version': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION, + 'local_epoch': _get_document_access_cache_local_epoch(), + 'scope_versions': scope_versions, + 'key_payload': key_payload or {}, + }) + return f'{DOCUMENT_ACCESS_CACHE_KEY_PREFIX}:{operation}:{source_scope}:{cache_hash}' + + +def _get_document_access_cache_local_epoch(): + with _document_access_cache_epoch_lock: + return f'{_document_access_cache_process_epoch}:{_document_access_cache_local_generation}' + + +def _bump_document_access_cache_local_epoch(): + global _document_access_cache_local_generation + with _document_access_cache_epoch_lock: + _document_access_cache_local_generation += 1 + return f'{_document_access_cache_process_epoch}:{_document_access_cache_local_generation}' + + +def _decode_cache_json(raw_value): + if raw_value is None: + return None + if isinstance(raw_value, bytes): + raw_value = raw_value.decode('utf-8') + return json.loads(raw_value) + + +def _get_document_access_cache_client( + normalized_settings, + operation, + source_scope, + scope_key_count, + require_cache_enabled=True, +): + if require_cache_enabled and not normalized_settings.get('cache_enabled'): + _record_document_access_cache_metric( + 'bypass', + operation=operation, + source_scope=source_scope, + scope_key_count=scope_key_count, + reason='disabled', + ) + return None + + redis_client = app_settings_cache.get_app_cache_redis_client() + if redis_client is None: + _record_document_access_cache_metric( + 'bypass', + operation=operation, + source_scope=source_scope, + scope_key_count=scope_key_count, + reason='redis_unavailable', + ) + return None + return redis_client + + +def _set_document_access_cache_version_key_if_missing(redis_client, version_key, ttl_seconds): + try: + return redis_client.set(version_key, 0, ex=ttl_seconds, nx=True) + except TypeError: + created = redis_client.setnx(version_key, 0) + if created: + redis_client.expire(version_key, ttl_seconds) + return created + + +def _refresh_document_access_cache_version_key_ttl(redis_client, version_key, ttl_seconds): + return redis_client.expire(version_key, ttl_seconds) + + +def _read_document_access_cache_scope_versions(redis_client, scope_keys, operation, source_scope, version_ttl_seconds): + scope_versions = [] + started_at = perf_counter() + try: + for scope_key in sorted(set(scope_keys or [])): + version_key = _document_access_cache_version_key(scope_key) + version_value = redis_client.get(version_key) + if version_value is None: + created = _set_document_access_cache_version_key_if_missing( + redis_client, + version_key, + version_ttl_seconds, + ) + if created: + version_value = 0 + else: + version_value = redis_client.get(version_key) + if version_value is None: + _set_document_access_cache_version_key_if_missing( + redis_client, + version_key, + version_ttl_seconds, + ) + version_value = 0 + else: + _refresh_document_access_cache_version_key_ttl( + redis_client, + version_key, + version_ttl_seconds, + ) + scope_versions.append({ + 'scope_hash': build_document_access_cache_scope_hash(scope_key), + 'version': _safe_int(version_value), + }) + except Exception as exc: + _record_document_access_cache_metric( + 'version_read_failed', + operation=operation, + source_scope=source_scope, + scope_key_count=len(scope_keys or []), + reason='redis_error', + elapsed_ms=(perf_counter() - started_at) * 1000, + error=exc, + ) + log_event( + '[DocumentAccessIndexCache] Failed to read Redis DAI cache scope versions; DAI query will run uncached.', + extra={ + 'operation': operation, + 'source_scope': source_scope, + 'scope_key_count': len(scope_keys or []), + 'error_type': type(exc).__name__, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + return scope_versions + + +def _build_document_access_cache_context(operation, source_scope, scope_keys, key_payload, normalized_settings): + redis_client = _get_document_access_cache_client( + normalized_settings, + operation, + source_scope, + len(scope_keys or []), + ) + if redis_client is None: + return None + + scope_versions = _read_document_access_cache_scope_versions( + redis_client, + scope_keys, + operation, + source_scope, + normalized_settings.get('cache_version_ttl_seconds'), + ) + if scope_versions is None: + return None + + return { + 'redis_client': redis_client, + 'cache_key': _document_access_cache_entry_key(operation, source_scope, scope_versions, key_payload), + 'operation': operation, + 'source_scope': source_scope, + 'scope_key_count': len(scope_keys or []), + 'ttl_seconds': normalized_settings.get('cache_ttl_seconds'), + } + + +def _try_get_document_access_cache_entry(cache_context): + if not cache_context: + return None + + started_at = perf_counter() + operation = cache_context.get('operation') + source_scope = cache_context.get('source_scope') + scope_key_count = cache_context.get('scope_key_count') + try: + cached_value = cache_context['redis_client'].get(cache_context['cache_key']) + except Exception as exc: + _record_document_access_cache_metric( + 'read_failed', + operation=operation, + source_scope=source_scope, + scope_key_count=scope_key_count, + reason='redis_error', + elapsed_ms=(perf_counter() - started_at) * 1000, + error=exc, + ) + log_event( + '[DocumentAccessIndexCache] Redis DAI cache read failed; DAI query will run uncached.', + extra={ + 'operation': operation, + 'source_scope': source_scope, + 'scope_key_count': scope_key_count, + 'error_type': type(exc).__name__, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + elapsed_ms = (perf_counter() - started_at) * 1000 + if cached_value is None: + _record_document_access_cache_metric( + 'miss', + operation=operation, + source_scope=source_scope, + scope_key_count=scope_key_count, + elapsed_ms=elapsed_ms, + ) + return None + + try: + payload = _decode_cache_json(cached_value) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + _record_document_access_cache_metric( + 'read_failed', + operation=operation, + source_scope=source_scope, + scope_key_count=scope_key_count, + reason='decode_failed', + elapsed_ms=elapsed_ms, + error=exc, + ) + return None + + _record_document_access_cache_metric( + 'hit', + operation=operation, + source_scope=source_scope, + scope_key_count=scope_key_count, + elapsed_ms=elapsed_ms, + ttl_seconds=cache_context.get('ttl_seconds'), + ) + return payload if isinstance(payload, dict) else None + + +def _try_set_document_access_cache_entry(cache_context, payload): + if not cache_context: + return False + + started_at = perf_counter() + operation = cache_context.get('operation') + source_scope = cache_context.get('source_scope') + scope_key_count = cache_context.get('scope_key_count') + ttl_seconds = _safe_int(cache_context.get('ttl_seconds'), DOCUMENT_ACCESS_DEFAULT_CACHE_TTL_SECONDS) + try: + cache_context['redis_client'].setex( + cache_context['cache_key'], + ttl_seconds, + json.dumps(payload or {}, default=str), + ) + except Exception as exc: + _record_document_access_cache_metric( + 'write_failed', + operation=operation, + source_scope=source_scope, + scope_key_count=scope_key_count, + reason='redis_error', + elapsed_ms=(perf_counter() - started_at) * 1000, + ttl_seconds=ttl_seconds, + error=exc, + ) + log_event( + '[DocumentAccessIndexCache] Redis DAI cache write failed; DAI read result was still returned.', + extra={ + 'operation': operation, + 'source_scope': source_scope, + 'scope_key_count': scope_key_count, + 'error_type': type(exc).__name__, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return False + + _record_document_access_cache_metric( + 'write', + operation=operation, + source_scope=source_scope, + scope_key_count=scope_key_count, + elapsed_ms=(perf_counter() - started_at) * 1000, + ttl_seconds=ttl_seconds, + ) + return True + + +def _document_access_scope_keys_from_document_item(document_item): + try: + rows = build_document_access_index_rows(document_item) + except Exception: + rows = [] + return sorted({ + row.get('scope_key') + for row in rows + if row.get('scope_key') + }) + + +def invalidate_document_access_index_cache_scope_keys(scope_keys, reason=None, settings=None): + """Bump Redis-only DAI cache versions for affected access scopes.""" + normalized_scope_keys = sorted({ + str(scope_key or '').strip() + for scope_key in scope_keys or [] + if str(scope_key or '').strip() + }) + if not normalized_scope_keys: + return { + 'success': True, + 'status': 'skipped_no_scope_keys', + 'invalidated_count': 0, + } + + operation = 'scope_invalidation' + source_scope = 'mixed' + normalized_settings = get_document_access_index_settings(settings) + redis_client = _get_document_access_cache_client( + normalized_settings, + operation, + source_scope, + len(normalized_scope_keys), + require_cache_enabled=False, + ) + if redis_client is None: + if normalized_settings.get('cache_enabled') and normalized_settings.get('redis_cache_configured'): + _record_document_access_cache_metric( + 'invalidate_failed', + operation=operation, + source_scope=source_scope, + scope_key_count=len(normalized_scope_keys), + reason='redis_unavailable', + ) + return { + 'success': False, + 'status': 'redis_unavailable', + 'invalidated_count': 0, + } + _bump_document_access_cache_local_epoch() + return { + 'success': True, + 'status': 'skipped_cache_unavailable', + 'invalidated_count': 0, + } + + invalidated_count = 0 + started_at = perf_counter() + try: + version_ttl_seconds = normalized_settings.get('cache_version_ttl_seconds') + for scope_key in normalized_scope_keys: + version_key = _document_access_cache_version_key(scope_key) + redis_client.incr(version_key) + _refresh_document_access_cache_version_key_ttl( + redis_client, + version_key, + version_ttl_seconds, + ) + invalidated_count += 1 + except Exception as exc: + _record_document_access_cache_metric( + 'invalidate_failed', + operation=operation, + source_scope=source_scope, + scope_key_count=len(normalized_scope_keys), + reason=reason or 'redis_error', + elapsed_ms=(perf_counter() - started_at) * 1000, + error=exc, + ) + log_event( + '[DocumentAccessIndexCache] Failed to invalidate Redis DAI cache scope versions.', + extra={ + 'scope_key_count': len(normalized_scope_keys), + 'invalidated_count': invalidated_count, + 'reason': reason, + 'error_type': type(exc).__name__, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return { + 'success': False, + 'status': 'invalidate_failed', + 'invalidated_count': invalidated_count, + } + + _record_document_access_cache_metric( + 'invalidate', + operation=operation, + source_scope=source_scope, + scope_key_count=invalidated_count, + reason=reason, + elapsed_ms=(perf_counter() - started_at) * 1000, + ) + return { + 'success': True, + 'status': 'invalidated', + 'invalidated_count': invalidated_count, + } + + +def _normalize_document_access_cache_scope_keys(scope_keys): + return sorted({ + str(scope_key or '').strip() + for scope_key in scope_keys or [] + if str(scope_key or '').strip() + }) + + +def _cache_scope_keys_from_exception(error): + return _normalize_document_access_cache_scope_keys(getattr(error, 'scope_keys', None)) + + +def _raise_for_failed_cache_invalidation(invalidation_result, operation, scope_keys=None): + if not isinstance(invalidation_result, dict) or invalidation_result.get('success'): + return + raise DocumentAccessIndexCacheInvalidationError( + operation, + invalidation_result.get('status') or 'unknown', + scope_keys=scope_keys, + ) + + +def _normalize_document_access_cache_version_hashes(scope_hashes): + return sorted({ + str(scope_hash or '').strip().lower() + for scope_hash in scope_hashes or [] + if re.fullmatch(r'[a-f0-9]{64}', str(scope_hash or '').strip().lower()) + }) + + +def _empty_document_access_cache_version_resolution(scope_hash): + return { + 'kind': 'document_access_index_scope_version', + 'label': 'DAI scope version marker', + 'resolved': False, + 'resolution_status': 'unresolved', + 'scope_hash': scope_hash, + 'scope_key': None, + 'entity_type': None, + 'entity_id': None, + 'entity_name': None, + 'entity_status': None, + 'row_count': None, + 'granted_row_count': None, + 'source_scopes': {}, + 'access_roles': {}, + 'note': 'No matching SimpleChat user, group, public workspace, or DAI projection scope was found.', + } + + +def _document_access_resolution_entity_type(scope_type): + if scope_type == DOCUMENT_ACCESS_SCOPE_GROUP: + return 'group_workspace' + if scope_type == DOCUMENT_ACCESS_SCOPE_PUBLIC: + return 'public_workspace' + if scope_type == DOCUMENT_ACCESS_PRINCIPAL_USER: + return 'user' + return scope_type + + +def _build_document_access_cache_version_resolution( + scope_hash, + scope_key, + entity_type, + entity_id, + entity_name=None, + entity_status=None, + row_count=None, + granted_row_count=None, + source_scopes=None, + access_roles=None, + source='document_access_index', +): + return { + 'kind': 'document_access_index_scope_version', + 'label': 'DAI scope version marker', + 'resolved': True, + 'resolution_status': 'resolved', + 'scope_hash': scope_hash, + 'scope_key': scope_key, + 'entity_type': _document_access_resolution_entity_type(entity_type), + 'entity_id': entity_id, + 'entity_name': entity_name, + 'entity_status': entity_status, + 'row_count': row_count, + 'granted_row_count': granted_row_count, + 'source_scopes': source_scopes or {}, + 'access_roles': access_roles or {}, + 'source': source, + 'note': 'Resolved by re-hashing known SimpleChat access scope keys.', + } + + +def _increment_counter(counter, key): + normalized_key = str(key or '').strip() or 'unknown' + counter[normalized_key] = counter.get(normalized_key, 0) + 1 + + +def _resolve_document_access_hashes_from_projection(scope_hashes, resolutions): + if not scope_hashes: + return + query = ( + 'SELECT c.scope_key, c.scope_type, c.scope_id, c.source_scope, c.access_role, c.access_granted ' + 'FROM c WHERE c.type = @type' + ) + projection_rows = cosmos_document_access_index_container.query_items( + query=query, + parameters=[{'name': '@type', 'value': DOCUMENT_ACCESS_INDEX_TYPE}], + enable_cross_partition_query=True, + ) + scope_summaries = {} + for row in projection_rows: + scope_key = str(row.get('scope_key') or '').strip() + if not scope_key: + continue + scope_hash = build_document_access_cache_scope_hash(scope_key) + if scope_hash not in scope_hashes: + continue + summary = scope_summaries.setdefault(scope_hash, { + 'scope_key': scope_key, + 'entity_type': row.get('scope_type'), + 'entity_id': row.get('scope_id'), + 'row_count': 0, + 'granted_row_count': 0, + 'source_scopes': {}, + 'access_roles': {}, + }) + summary['row_count'] += 1 + if row.get('access_granted'): + summary['granted_row_count'] += 1 + _increment_counter(summary['source_scopes'], row.get('source_scope')) + _increment_counter(summary['access_roles'], row.get('access_role')) + + for scope_hash, summary in scope_summaries.items(): + resolutions[scope_hash] = _build_document_access_cache_version_resolution( + scope_hash, + summary.get('scope_key'), + summary.get('entity_type'), + summary.get('entity_id'), + row_count=summary.get('row_count'), + granted_row_count=summary.get('granted_row_count'), + source_scopes=summary.get('source_scopes'), + access_roles=summary.get('access_roles'), + ) + + +def _resolve_document_access_hashes_from_workspace_container( + scope_hashes, + resolutions, + container, + scope_type, + entity_status_default='active', +): + if not scope_hashes: + return + query = 'SELECT c.id, c.name, c.status FROM c' + for item in container.query_items(query=query, enable_cross_partition_query=True): + entity_id = str(item.get('id') or '').strip() + if not entity_id: + continue + scope_key = build_document_access_scope_key(scope_type, entity_id) + scope_hash = build_document_access_cache_scope_hash(scope_key) + if scope_hash not in scope_hashes: + continue + if resolutions.get(scope_hash, {}).get('resolved'): + resolutions[scope_hash]['entity_name'] = item.get('name') + resolutions[scope_hash]['entity_status'] = item.get('status') or entity_status_default + continue + resolutions[scope_hash] = _build_document_access_cache_version_resolution( + scope_hash, + scope_key, + scope_type, + entity_id, + entity_name=item.get('name'), + entity_status=item.get('status') or entity_status_default, + row_count=0, + granted_row_count=0, + source='workspace_container', + ) + + +def _resolve_document_access_hashes_from_user_settings(scope_hashes, resolutions): + if not scope_hashes: + return + query = 'SELECT c.id, c.displayName, c.name FROM c' + for item in cosmos_user_settings_container.query_items(query=query, enable_cross_partition_query=True): + entity_id = str(item.get('id') or '').strip() + if not entity_id: + continue + scope_key = build_document_access_scope_key(DOCUMENT_ACCESS_PRINCIPAL_USER, entity_id) + scope_hash = build_document_access_cache_scope_hash(scope_key) + if scope_hash not in scope_hashes: + continue + if resolutions.get(scope_hash, {}).get('resolved'): + resolutions[scope_hash]['entity_name'] = item.get('displayName') or item.get('name') + continue + resolutions[scope_hash] = _build_document_access_cache_version_resolution( + scope_hash, + scope_key, + DOCUMENT_ACCESS_PRINCIPAL_USER, + entity_id, + entity_name=item.get('displayName') or item.get('name'), + row_count=0, + granted_row_count=0, + source='user_settings', + ) + + +def resolve_document_access_cache_version_hashes(scope_hashes): + """Resolve Redis DAI version marker hashes to safe SimpleChat scope metadata.""" + normalized_hashes = _normalize_document_access_cache_version_hashes(scope_hashes) + resolutions = { + scope_hash: _empty_document_access_cache_version_resolution(scope_hash) + for scope_hash in normalized_hashes + } + if not normalized_hashes: + return resolutions + + try: + _resolve_document_access_hashes_from_projection(set(normalized_hashes), resolutions) + except Exception as exc: + log_event( + '[DocumentAccessIndexCache] Failed to resolve DAI Redis hashes from projection rows.', + extra={'hash_count': len(normalized_hashes), 'error_type': type(exc).__name__}, + level=logging.WARNING, + exceptionTraceback=True, + ) + + try: + _resolve_document_access_hashes_from_workspace_container( + set(normalized_hashes), + resolutions, + cosmos_groups_container, + DOCUMENT_ACCESS_SCOPE_GROUP, + ) + except Exception as exc: + log_event( + '[DocumentAccessIndexCache] Failed to resolve DAI Redis hashes from group workspaces.', + extra={'hash_count': len(normalized_hashes), 'error_type': type(exc).__name__}, + level=logging.WARNING, + exceptionTraceback=True, + ) + + try: + _resolve_document_access_hashes_from_workspace_container( + set(normalized_hashes), + resolutions, + cosmos_public_workspaces_container, + DOCUMENT_ACCESS_SCOPE_PUBLIC, + ) + except Exception as exc: + log_event( + '[DocumentAccessIndexCache] Failed to resolve DAI Redis hashes from public workspaces.', + extra={'hash_count': len(normalized_hashes), 'error_type': type(exc).__name__}, + level=logging.WARNING, + exceptionTraceback=True, + ) + + try: + _resolve_document_access_hashes_from_user_settings(set(normalized_hashes), resolutions) + except Exception as exc: + log_event( + '[DocumentAccessIndexCache] Failed to resolve DAI Redis hashes from user settings.', + extra={'hash_count': len(normalized_hashes), 'error_type': type(exc).__name__}, + level=logging.WARNING, + exceptionTraceback=True, + ) + + return resolutions + + +def refresh_document_access_cache_version_marker_ttls(settings=None, redis_client=None, batch_size=None): + """Apply the derived TTL policy to legacy DAI Redis version markers.""" + normalized_settings = get_document_access_index_settings(settings) + resolved_client = redis_client or _get_document_access_cache_client( + normalized_settings, + 'version_marker_hygiene', + 'mixed', + 0, + require_cache_enabled=False, + ) + version_ttl_seconds = normalized_settings.get('cache_version_ttl_seconds') + payload_ttl_seconds = normalized_settings.get('cache_ttl_seconds') + result = { + 'success': True, + 'status': 'skipped_redis_unavailable', + 'version_marker_ttl_seconds': version_ttl_seconds, + 'payload_ttl_seconds': payload_ttl_seconds, + 'scanned_count': 0, + 'refreshed_count': 0, + 'no_expiry_count': 0, + 'unsafe_ttl_count': 0, + 'skipped_count': 0, + 'scan_iterations': 0, + 'has_more': False, + } + if resolved_client is None: + return result + + normalized_batch_size = _normalize_positive_int( + batch_size, + DOCUMENT_ACCESS_CACHE_VERSION_HYGIENE_BATCH_SIZE, + min_value=1, + max_value=1000, + ) + cursor = 0 + try: + while result['scan_iterations'] < DOCUMENT_ACCESS_CACHE_VERSION_HYGIENE_MAX_SCAN_ITERATIONS: + cursor, keys = resolved_client.scan( + cursor=cursor, + match=f'{DOCUMENT_ACCESS_CACHE_VERSION_KEY_PREFIX}:*', + count=normalized_batch_size, + ) + cursor = _safe_int(cursor) + result['scan_iterations'] += 1 + for key in list(keys or []): + result['scanned_count'] += 1 + try: + ttl_seconds = int(resolved_client.ttl(key)) + except (TypeError, ValueError): + ttl_seconds = -2 + if ttl_seconds == -1: + result['no_expiry_count'] += 1 + if ttl_seconds == -1 or (ttl_seconds >= 0 and ttl_seconds < payload_ttl_seconds): + if ttl_seconds >= 0 and ttl_seconds < payload_ttl_seconds: + result['unsafe_ttl_count'] += 1 + _refresh_document_access_cache_version_key_ttl( + resolved_client, + key, + version_ttl_seconds, + ) + result['refreshed_count'] += 1 + else: + result['skipped_count'] += 1 + if cursor == 0: + break + result['has_more'] = cursor != 0 + result['status'] = 'completed' + return result + except Exception as exc: + log_event( + '[DocumentAccessIndexCache] Failed to refresh DAI Redis version marker TTLs.', + extra={ + 'scanned_count': result.get('scanned_count'), + 'refreshed_count': result.get('refreshed_count'), + 'error_type': type(exc).__name__, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + result['success'] = False + result['status'] = 'failed' + result['error'] = str(exc) + return result + + +def build_document_access_index_row_id(scope_key, source_scope, document_id, version): + """Build a deterministic Cosmos id for one scope/document/version projection row.""" + raw_id = ':'.join([ + 'dai', + _safe_id_part(scope_key), + _safe_id_part(source_scope), + _safe_id_part(document_id), + _safe_id_part(version), + ]) + if len(raw_id) <= 900: + return raw_id + return f'dai:{hashlib.sha256(raw_id.encode("utf-8")).hexdigest()}' + + +def _normalize_string_list(value): + if not isinstance(value, list): + return [] + return [ + str(item).strip() + for item in value + if str(item or '').strip() + ] + + +def _normalize_filter_text(value): + return str(value or '').strip().lower() + + +def _has_persisted_blob_reference(document_item): + if not isinstance(document_item, dict): + return False + if document_item.get('blob_path'): + return True + return ( + document_item.get('blob_path_mode') == DOCUMENT_ACCESS_ARCHIVED_REVISION_BLOB_PATH_MODE + and bool(document_item.get('archived_blob_path')) + ) + + +def _document_identity(document_item): + if not isinstance(document_item, dict): + return None + document_id = str( + document_item.get('document_id') + or document_item.get('source_document_id') + or document_item.get('id') + or '' + ).strip() + try: + version = int(document_item.get('version') or 0) + except (TypeError, ValueError): + version = 0 + if not document_id: + return None + return f'{document_id}:{version}' + + +def _document_family_identity(document_item, source_scope): + if not isinstance(document_item, dict): + return None + revision_family_id = str(document_item.get('revision_family_id') or '').strip() + if revision_family_id: + return revision_family_id + + if source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC: + scope_value = document_item.get('owner_public_workspace_id') or document_item.get('public_workspace_id') + elif source_scope == DOCUMENT_ACCESS_SCOPE_GROUP: + scope_value = document_item.get('owner_group_id') or document_item.get('group_id') + else: + scope_value = document_item.get('owner_user_id') or document_item.get('user_id') + return f'legacy::{scope_value or "unknown"}::{document_item.get("file_name") or ""}' + + +def _normalize_share_entry(entry): + normalized_entry = str(entry or '').strip() + if not normalized_entry: + return None + if ',' in normalized_entry: + scope_id, approval_status = normalized_entry.split(',', 1) + approval_status = str(approval_status or '').strip().lower() or 'unknown' + else: + scope_id = normalized_entry + approval_status = DOCUMENT_ACCESS_APPROVAL_NOT_APPROVED + + scope_id = str(scope_id or '').strip() + if not scope_id: + return None + return { + 'scope_id': scope_id, + 'approval_status': approval_status, + 'raw_entry': normalized_entry, + } + + +def _resolve_source_scope(document_item): + if document_item.get('public_workspace_id'): + return DOCUMENT_ACCESS_SCOPE_PUBLIC + if document_item.get('group_id'): + return DOCUMENT_ACCESS_SCOPE_GROUP + return DOCUMENT_ACCESS_SCOPE_PERSONAL + + +def _source_scope_container_name(source_scope): + if source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC: + return 'public_documents' + if source_scope == DOCUMENT_ACCESS_SCOPE_GROUP: + return 'group_documents' + return 'documents' + + +def _get_document_version(document_item): + try: + return int(document_item.get('version') or 0) + except (TypeError, ValueError): + return 0 + + +def _build_base_row(document_item, source_scope, scope_type, scope_id, access_role, approval_status, projected_at): + document_id = str(document_item.get('id') or document_item.get('document_id') or '').strip() + version = _get_document_version(document_item) + scope_key = build_document_access_scope_key(scope_type, scope_id) + if not document_id or not scope_key: + return None + + approval_status = str(approval_status or '').strip().lower() or 'unknown' + access_granted = approval_status == DOCUMENT_ACCESS_APPROVAL_APPROVED + source_updated_at = document_item.get('last_updated') or document_item.get('upload_date') + + return { + 'id': build_document_access_index_row_id(scope_key, source_scope, document_id, version), + 'type': DOCUMENT_ACCESS_INDEX_TYPE, + 'schema_version': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION, + 'projection_status': 'ready', + 'projection_version': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION, + 'repair_required': False, + 'projected_at': projected_at, + 'scope_key': scope_key, + 'scope_type': scope_type, + 'scope_id': str(scope_id or '').strip(), + 'source_scope': source_scope, + 'source_container': _source_scope_container_name(source_scope), + 'source_document_id': document_id, + 'source_partition_key': document_id, + 'document_id': document_id, + 'revision_family_id': document_item.get('revision_family_id'), + 'version': version, + 'is_current_version': bool(document_item.get('is_current_version', True)), + 'search_visibility_state': str(document_item.get('search_visibility_state') or 'active').strip().lower(), + 'access_role': access_role, + 'approval_status': approval_status, + 'access_granted': access_granted, + 'shared_user_ids': _normalize_string_list(document_item.get('shared_user_ids')), + 'shared_group_ids': _normalize_string_list(document_item.get('shared_group_ids')), + 'file_name': document_item.get('file_name'), + 'title': document_item.get('title'), + 'document_classification': document_item.get('document_classification'), + 'tags': _normalize_string_list(document_item.get('tags')), + 'authors': _normalize_string_list(document_item.get('authors')), + 'keywords': _normalize_string_list(document_item.get('keywords')), + 'abstract': document_item.get('abstract'), + 'status': document_item.get('status'), + 'percentage_complete': document_item.get('percentage_complete'), + 'number_of_pages': document_item.get('number_of_pages'), + 'publication_date': document_item.get('publication_date'), + 'enhanced_citations': _has_persisted_blob_reference(document_item), + 'document_intelligence_extraction_mode': document_item.get('document_intelligence_extraction_mode'), + 'generated_artifact_promotion_status': document_item.get('generated_artifact_promotion_status'), + 'generated_artifact_requested_by_user_id': document_item.get('generated_artifact_requested_by_user_id'), + 'file_sync': document_item.get('file_sync'), + 'created_from_chat_upload': document_item.get('created_from_chat_upload'), + 'conversation_id': document_item.get('conversation_id'), + 'conversation_title_at_upload': document_item.get('conversation_title_at_upload'), + 'upload_date': document_item.get('upload_date'), + 'last_updated': document_item.get('last_updated'), + 'source_ts': document_item.get('_ts'), + 'source_updated_at': source_updated_at, + 'user_id': document_item.get('user_id'), + 'owner_user_id': document_item.get('user_id'), + 'owner_group_id': document_item.get('group_id'), + 'owner_public_workspace_id': document_item.get('public_workspace_id'), + } + + +def _prefer_projection_row(existing_row, candidate_row): + if not existing_row: + return candidate_row + existing_sort_key = ( + _safe_int(existing_row.get('version')), + str(existing_row.get('upload_date') or ''), + _safe_int(existing_row.get('source_ts')), + ) + candidate_sort_key = ( + _safe_int(candidate_row.get('version')), + str(candidate_row.get('upload_date') or ''), + _safe_int(candidate_row.get('source_ts')), + ) + if candidate_sort_key > existing_sort_key: + return candidate_row + if candidate_sort_key < existing_sort_key: + return existing_row + if existing_row.get('access_role') == 'owner': + return existing_row + if candidate_row.get('access_role') == 'owner': + return candidate_row + if existing_row.get('access_granted') and not candidate_row.get('access_granted'): + return candidate_row + return existing_row + + +def build_document_access_index_rows(document_item): + """Build deterministic per-scope projection rows for one source document.""" + if not isinstance(document_item, dict): + return [] + + source_scope = _resolve_source_scope(document_item) + projected_at = _utc_now_iso() + rows_by_key = {} + + def add_row(scope_type, scope_id, access_role, approval_status): + row = _build_base_row( + document_item, + source_scope, + scope_type, + scope_id, + access_role, + approval_status, + projected_at, + ) + if not row: + return + row_key = (row['scope_key'], row['id']) + rows_by_key[row_key] = _prefer_projection_row(rows_by_key.get(row_key), row) + + if source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC: + add_row( + DOCUMENT_ACCESS_SCOPE_PUBLIC, + document_item.get('public_workspace_id'), + 'public_workspace', + DOCUMENT_ACCESS_APPROVAL_APPROVED, + ) + elif source_scope == DOCUMENT_ACCESS_SCOPE_GROUP: + add_row( + DOCUMENT_ACCESS_SCOPE_GROUP, + document_item.get('group_id'), + 'owner', + DOCUMENT_ACCESS_APPROVAL_APPROVED, + ) + for share_entry in _normalize_string_list(document_item.get('shared_group_ids')): + normalized_share = _normalize_share_entry(share_entry) + if normalized_share: + add_row( + DOCUMENT_ACCESS_SCOPE_GROUP, + normalized_share['scope_id'], + 'shared_group', + normalized_share['approval_status'], + ) + else: + add_row( + DOCUMENT_ACCESS_PRINCIPAL_USER, + document_item.get('user_id'), + 'owner', + DOCUMENT_ACCESS_APPROVAL_APPROVED, + ) + for share_entry in _normalize_string_list(document_item.get('shared_user_ids')): + normalized_share = _normalize_share_entry(share_entry) + if normalized_share: + add_row( + DOCUMENT_ACCESS_PRINCIPAL_USER, + normalized_share['scope_id'], + 'shared_user', + normalized_share['approval_status'], + ) + + return sorted(rows_by_key.values(), key=lambda row: (row['scope_key'], row['id'])) + + +def _query_existing_projection_rows(source_scope, document_id): + query = ( + 'SELECT c.id, c.scope_key FROM c ' + 'WHERE c.type = @type AND c.source_scope = @source_scope AND c.source_document_id = @document_id' + ) + return list(cosmos_document_access_index_container.query_items( + query=query, + parameters=[ + {'name': '@type', 'value': DOCUMENT_ACCESS_INDEX_TYPE}, + {'name': '@source_scope', 'value': source_scope}, + {'name': '@document_id', 'value': document_id}, + ], + enable_cross_partition_query=True, + )) + + +def _read_shadow_validation_state(use_cache=True): + if use_cache: + cache_hit, cached_state = _get_cached_document_access_state(DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID) + if cache_hit: + return cached_state + try: + state = cosmos_settings_container.read_item( + item=DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID, + partition_key=DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID, + ) + except Exception as exc: + if _is_not_found_error(exc): + _set_cached_document_access_state(DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID, None) + return None + log_event( + '[DocumentAccessIndex] Failed to read document access shadow validation state.', + extra={'error': str(exc)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + _set_cached_document_access_state(DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID, state) + return state + + +def _empty_shadow_metric_window(window_minutes): + return { + 'window_minutes': window_minutes, + 'sample_count': 0, + 'comparable_sample_count': 0, + 'matched_count': 0, + 'mismatch_count': 0, + 'error_count': 0, + 'source_query_ru': None, + 'validation_index_ru': None, + 'candidate_read_ru': None, + 'estimated_wave5_ru_savings': None, + 'shadow_overhead_ru': None, + 'source_query_ms_avg': None, + 'candidate_read_ms_avg': None, + 'estimated_wave5_ms_savings_avg': None, + 'source_query_item_count': 0, + 'candidate_read_item_count': 0, + 'source_query_page_count': 0, + 'candidate_read_page_count': 0, + 'first_checked_at': None, + 'last_checked_at': None, + } + + +def _empty_shadow_rolling_metrics(): + return { + 'updated_at': None, + 'retention_minutes': DOCUMENT_ACCESS_SHADOW_METRIC_RETENTION_MINUTES, + 'sample_limit': DOCUMENT_ACCESS_SHADOW_METRIC_MAX_SAMPLES, + 'sample_count': 0, + 'windows': { + f'{window_minutes}m': _empty_shadow_metric_window(window_minutes) + for window_minutes in DOCUMENT_ACCESS_SHADOW_METRIC_WINDOWS_MINUTES + }, + } + + +def _build_shadow_metric_sample(state_body): + if not isinstance(state_body, dict): + return None + + metric_keys = ( + 'source_query_ru', + 'validation_index_ru', + 'candidate_read_ru', + 'source_query_ms', + 'candidate_read_ms', + ) + if not any(_safe_float(state_body.get(metric_key)) is not None for metric_key in metric_keys): + return None + + scope_keys = state_body.get('scope_keys') + return { + 'checked_at': state_body.get('checked_at') or _utc_now_iso(), + 'status': str(state_body.get('status') or 'unknown').strip().lower() or 'unknown', + 'context': str(state_body.get('context') or '').strip(), + 'source_scope': str(state_body.get('source_scope') or '').strip().lower(), + 'scope_key_count': len(scope_keys) if isinstance(scope_keys, list) else 0, + 'authoritative_count': _safe_int(state_body.get('authoritative_count')), + 'projection_count': _safe_int(state_body.get('projection_count')), + 'missing_count': _safe_int(state_body.get('missing_count')), + 'extra_count': _safe_int(state_body.get('extra_count')), + 'source_query_ru': _round_metric(state_body.get('source_query_ru')), + 'validation_index_ru': _round_metric(state_body.get('validation_index_ru')), + 'candidate_read_ru': _round_metric(state_body.get('candidate_read_ru')), + 'estimated_wave5_ru_savings': _round_metric(state_body.get('estimated_wave5_ru_savings')), + 'shadow_overhead_ru': _round_metric(state_body.get('shadow_overhead_ru')), + 'source_query_ms': _round_metric(state_body.get('source_query_ms')), + 'candidate_read_ms': _round_metric(state_body.get('candidate_read_ms')), + 'estimated_wave5_ms_savings': _round_metric(state_body.get('estimated_wave5_ms_savings')), + 'source_query_item_count': _safe_int(state_body.get('source_query_item_count')), + 'candidate_read_item_count': _safe_int(state_body.get('candidate_read_item_count')), + 'source_query_page_count': _safe_int(state_body.get('source_query_page_count')), + 'candidate_read_page_count': _safe_int(state_body.get('candidate_read_page_count')), + } + + +def _prune_shadow_metric_samples(samples, now): + cutoff = now - timedelta(minutes=DOCUMENT_ACCESS_SHADOW_METRIC_RETENTION_MINUTES) + pruned_samples = [] + for sample in samples or []: + if not isinstance(sample, dict): + continue + checked_at = _parse_utc_datetime(sample.get('checked_at')) + if not checked_at or checked_at < cutoff: + continue + pruned_samples.append(copy.deepcopy(sample)) + + pruned_samples.sort(key=lambda sample: sample.get('checked_at') or '') + return pruned_samples[-DOCUMENT_ACCESS_SHADOW_METRIC_MAX_SAMPLES:] + + +def _build_shadow_metric_window(samples, now, window_minutes): + cutoff = now - timedelta(minutes=window_minutes) + window_samples = [ + sample + for sample in samples or [] + if (_parse_utc_datetime(sample.get('checked_at')) or datetime.min.replace(tzinfo=timezone.utc)) >= cutoff + ] + if not window_samples: + return _empty_shadow_metric_window(window_minutes) + + paired_ru_samples = [ + sample + for sample in window_samples + if _safe_float(sample.get('source_query_ru')) is not None + and _safe_float(sample.get('candidate_read_ru')) is not None + ] + paired_ms_samples = [ + sample + for sample in window_samples + if _safe_float(sample.get('source_query_ms')) is not None + and _safe_float(sample.get('candidate_read_ms')) is not None + ] + paired_source_ru = _sum_metric_values(paired_ru_samples, 'source_query_ru') + paired_candidate_ru = _sum_metric_values(paired_ru_samples, 'candidate_read_ru') + source_ms_avg = _average_metric_values(paired_ms_samples, 'source_query_ms') + candidate_ms_avg = _average_metric_values(paired_ms_samples, 'candidate_read_ms') + + return { + 'window_minutes': window_minutes, + 'sample_count': len(window_samples), + 'comparable_sample_count': len(paired_ru_samples), + 'matched_count': sum(1 for sample in window_samples if sample.get('status') == 'matched'), + 'mismatch_count': sum(1 for sample in window_samples if sample.get('status') == 'mismatch'), + 'error_count': sum(1 for sample in window_samples if sample.get('status') == 'error'), + 'source_query_ru': paired_source_ru, + 'validation_index_ru': _sum_metric_values(window_samples, 'validation_index_ru'), + 'candidate_read_ru': paired_candidate_ru, + 'estimated_wave5_ru_savings': _metric_delta(paired_source_ru, paired_candidate_ru), + 'shadow_overhead_ru': _sum_metric_values(window_samples, 'shadow_overhead_ru'), + 'source_query_ms_avg': source_ms_avg, + 'candidate_read_ms_avg': candidate_ms_avg, + 'estimated_wave5_ms_savings_avg': _metric_delta(source_ms_avg, candidate_ms_avg), + 'source_query_item_count': _sum_int_values(paired_ru_samples, 'source_query_item_count'), + 'candidate_read_item_count': _sum_int_values(paired_ru_samples, 'candidate_read_item_count'), + 'source_query_page_count': _sum_int_values(paired_ru_samples, 'source_query_page_count'), + 'candidate_read_page_count': _sum_int_values(paired_ru_samples, 'candidate_read_page_count'), + 'first_checked_at': window_samples[0].get('checked_at'), + 'last_checked_at': window_samples[-1].get('checked_at'), + } + + +def _build_shadow_rolling_metrics(samples, now): + return { + 'updated_at': now.isoformat(), + 'retention_minutes': DOCUMENT_ACCESS_SHADOW_METRIC_RETENTION_MINUTES, + 'sample_limit': DOCUMENT_ACCESS_SHADOW_METRIC_MAX_SAMPLES, + 'sample_count': len(samples or []), + 'windows': { + f'{window_minutes}m': _build_shadow_metric_window(samples, now, window_minutes) + for window_minutes in DOCUMENT_ACCESS_SHADOW_METRIC_WINDOWS_MINUTES + }, + } + + +def _merge_shadow_rolling_metrics(state_body, previous_state=None): + previous_state = previous_state or {} + samples = _prune_shadow_metric_samples( + previous_state.get('recent_metric_samples') if isinstance(previous_state, dict) else [], + datetime.now(timezone.utc), + ) + metric_sample = _build_shadow_metric_sample(state_body) + if metric_sample: + samples.append(metric_sample) + now = datetime.now(timezone.utc) + samples = _prune_shadow_metric_samples(samples, now) + state_body['recent_metric_samples'] = samples + state_body['rolling_metrics'] = _build_shadow_rolling_metrics(samples, now) + return state_body + + +def _write_shadow_validation_state(state): + state_body = None + last_conflict = None + + for attempt in range(DOCUMENT_ACCESS_SHADOW_STATE_WRITE_MAX_RETRIES): + previous_state = _read_shadow_validation_state(use_cache=False) + state_body = copy.deepcopy(state or {}) + state_body.update({ + 'id': DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID, + 'type': DOCUMENT_ACCESS_SHADOW_STATE_TYPE, + 'updated_at': _utc_now_iso(), + 'schema_version': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION, + }) + state_body = _merge_shadow_rolling_metrics(state_body, previous_state=previous_state) + + try: + if previous_state: + replace_kwargs = {} + etag = previous_state.get('_etag') if isinstance(previous_state, dict) else None + if etag: + replace_kwargs['etag'] = etag + replace_kwargs['match_condition'] = MatchConditions.IfNotModified + cosmos_settings_container.replace_item( + item=DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID, + body=state_body, + **replace_kwargs, + ) + else: + cosmos_settings_container.create_item(body=state_body) + _set_cached_document_access_state(DOCUMENT_ACCESS_SHADOW_STATE_DOC_ID, state_body) + return state_body + except Exception as exc: + if _is_write_conflict_error(exc): + last_conflict = exc + log_event( + '[DocumentAccessIndex] Retrying shadow validation state write after ETag conflict.', + extra={'attempt': attempt + 1, 'error': str(exc)}, + level=logging.WARNING, + debug_only=True, + ) + continue + raise + + log_event( + '[DocumentAccessIndex] Failed to persist shadow validation state after ETag conflicts.', + extra={ + 'attempt_count': DOCUMENT_ACCESS_SHADOW_STATE_WRITE_MAX_RETRIES, + 'error': str(last_conflict) if last_conflict else None, + }, + level=logging.WARNING, + ) + return state_body + + +def _query_projection_rows_for_scope(scope_key, source_scope): + rows, _diagnostics = _query_projection_rows_for_scope_with_diagnostics(scope_key, source_scope) + return rows + + +def _query_projection_rows_for_scope_with_diagnostics(scope_key, source_scope): + query = ( + 'SELECT * FROM c ' + 'WHERE c.type = @type ' + 'AND c.source_scope = @source_scope ' + 'AND c.scope_key = @scope_key ' + 'AND (c.access_granted = true OR c.approval_status = @approval_not_approved) ' + 'AND c.is_current_version = true ' + 'AND c.projection_version = @projection_version' + ) + return query_items_with_cosmos_diagnostics( + cosmos_document_access_index_container, + diagnostics_label=f'projection:{scope_key}', + query=query, + parameters=[ + {'name': '@type', 'value': DOCUMENT_ACCESS_INDEX_TYPE}, + {'name': '@source_scope', 'value': source_scope}, + {'name': '@scope_key', 'value': scope_key}, + {'name': '@approval_not_approved', 'value': DOCUMENT_ACCESS_APPROVAL_NOT_APPROVED}, + {'name': '@projection_version', 'value': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION}, + ], + partition_key=scope_key, + ) + + +def _query_candidate_projection_rows_for_scope(scope_key, source_scope): + query = ( + 'SELECT c.id, c.document_id, c.source_document_id, c.version, c.scope_key, ' + 'c.scope_id, c.access_role, c.approval_status, c.access_granted, ' + 'c.shared_user_ids, c.shared_group_ids, c.file_name, c.title, c.document_classification, c.tags, c.authors, c.keywords, ' + 'c.abstract, c.status, c.percentage_complete, c.number_of_pages, c.publication_date, ' + 'c.enhanced_citations, c.document_intelligence_extraction_mode, ' + 'c.generated_artifact_promotion_status, c.generated_artifact_requested_by_user_id, ' + 'c.file_sync, c.created_from_chat_upload, ' + 'c.conversation_id, c.conversation_title_at_upload, c.upload_date, c.last_updated, ' + 'c.revision_family_id, c.search_visibility_state, c.source_ts, ' + 'c.user_id, c.owner_user_id, c.owner_group_id, c.owner_public_workspace_id ' + 'FROM c ' + 'WHERE c.type = @type ' + 'AND c.source_scope = @source_scope ' + 'AND c.scope_key = @scope_key ' + 'AND (c.access_granted = true OR c.approval_status = @approval_not_approved) ' + 'AND c.is_current_version = true ' + 'AND c.projection_version = @projection_version' + ) + return query_items_with_cosmos_diagnostics( + cosmos_document_access_index_container, + diagnostics_label=f'candidate_read:{scope_key}', + query=query, + parameters=[ + {'name': '@type', 'value': DOCUMENT_ACCESS_INDEX_TYPE}, + {'name': '@source_scope', 'value': source_scope}, + {'name': '@scope_key', 'value': scope_key}, + {'name': '@approval_not_approved', 'value': DOCUMENT_ACCESS_APPROVAL_NOT_APPROVED}, + {'name': '@projection_version', 'value': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION}, + ], + partition_key=scope_key, + ) + + +def _query_tag_projection_rows_for_scope(scope_key, source_scope): + query = ( + 'SELECT c.document_id, c.source_document_id, c.version, c.scope_key, ' + 'c.scope_id, c.access_role, c.access_granted, c.file_name, c.tags, c.revision_family_id, ' + 'c.source_ts, c.owner_user_id, c.owner_group_id, c.owner_public_workspace_id ' + 'FROM c ' + 'WHERE c.type = @type ' + 'AND c.source_scope = @source_scope ' + 'AND c.scope_key = @scope_key ' + 'AND c.access_granted = true ' + 'AND c.is_current_version = true ' + 'AND c.projection_version = @projection_version ' + 'AND IS_DEFINED(c.tags) ' + 'AND ARRAY_LENGTH(c.tags) > 0' + ) + return query_items_with_cosmos_diagnostics( + cosmos_document_access_index_container, + diagnostics_label=f'tag_read:{scope_key}', + query=query, + parameters=[ + {'name': '@type', 'value': DOCUMENT_ACCESS_INDEX_TYPE}, + {'name': '@source_scope', 'value': source_scope}, + {'name': '@scope_key', 'value': scope_key}, + {'name': '@projection_version', 'value': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION}, + ], + partition_key=scope_key, + ) + + +def _query_legacy_projection_rows_for_scope(scope_key, source_scope, access_role): + query = ( + 'SELECT c.document_id, c.source_document_id, c.version, c.revision_family_id, ' + 'c.file_name, c.owner_user_id, c.owner_group_id, c.owner_public_workspace_id ' + 'FROM c ' + 'WHERE c.type = @type ' + 'AND c.source_scope = @source_scope ' + 'AND c.scope_key = @scope_key ' + 'AND c.access_role = @access_role ' + 'AND c.access_granted = true ' + 'AND c.projection_version = @projection_version ' + 'AND (NOT IS_DEFINED(c.percentage_complete) OR IS_NULL(c.percentage_complete))' + ) + return list(cosmos_document_access_index_container.query_items( + query=query, + parameters=[ + {'name': '@type', 'value': DOCUMENT_ACCESS_INDEX_TYPE}, + {'name': '@source_scope', 'value': source_scope}, + {'name': '@scope_key', 'value': scope_key}, + {'name': '@access_role', 'value': access_role}, + {'name': '@projection_version', 'value': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION}, + ], + partition_key=scope_key, + )) + + +def _collect_candidate_read_metrics( + scope_keys, + source_scope, +): + diagnostics = [] + errors = [] + for scope_key in scope_keys: + try: + _rows, scope_diagnostics = _query_candidate_projection_rows_for_scope( + scope_key, + source_scope, + ) + diagnostics.append(scope_diagnostics) + except Exception as exc: + errors.append(str(exc)) + log_event( + '[DocumentAccessIndex] Candidate read metrics query failed.', + extra={ + 'scope_key': scope_key, + 'source_scope': source_scope, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + combined = _combine_query_diagnostics('candidate_read', diagnostics) + if errors: + combined['errors'] = errors[:DOCUMENT_ACCESS_SHADOW_MAX_SAMPLE_IDS] + combined['error_count'] = len(errors) + combined['partial_failure'] = True + return combined + + +def _projection_row_to_document(row, source_scope): + document_id = str(row.get('source_document_id') or row.get('document_id') or '').strip() + approval_status = str(row.get('approval_status') or DOCUMENT_ACCESS_APPROVAL_APPROVED).strip().lower() + scope_id = str(row.get('scope_id') or '').strip() + access_role = str(row.get('access_role') or '').strip().lower() + document_item = { + 'id': document_id, + 'document_id': document_id, + 'file_name': row.get('file_name'), + 'title': row.get('title'), + 'document_classification': row.get('document_classification'), + 'tags': _normalize_string_list(row.get('tags')), + 'authors': _normalize_string_list(row.get('authors')), + 'keywords': _normalize_string_list(row.get('keywords')), + 'abstract': row.get('abstract'), + 'status': row.get('status'), + 'percentage_complete': row.get('percentage_complete'), + 'number_of_pages': row.get('number_of_pages'), + 'publication_date': row.get('publication_date'), + 'enhanced_citations': row.get('enhanced_citations'), + 'document_intelligence_extraction_mode': row.get('document_intelligence_extraction_mode'), + 'generated_artifact_promotion_status': row.get('generated_artifact_promotion_status'), + 'generated_artifact_requested_by_user_id': row.get('generated_artifact_requested_by_user_id'), + 'file_sync': row.get('file_sync'), + 'created_from_chat_upload': row.get('created_from_chat_upload'), + 'conversation_id': row.get('conversation_id'), + 'conversation_title_at_upload': row.get('conversation_title_at_upload'), + 'upload_date': row.get('upload_date'), + 'last_updated': row.get('last_updated'), + 'revision_family_id': row.get('revision_family_id') or document_id, + 'version': _get_document_version(row), + 'is_current_version': True, + 'search_visibility_state': str(row.get('search_visibility_state') or 'active').strip().lower(), + '_ts': _safe_int(row.get('source_ts')), + } + + if source_scope == DOCUMENT_ACCESS_SCOPE_GROUP: + owner_group_id = row.get('owner_group_id') + document_item['group_id'] = owner_group_id + document_item['owner_group_id'] = owner_group_id + document_item['shared_group_ids'] = _normalize_string_list(row.get('shared_group_ids')) + if access_role == 'shared_group' and scope_id: + shared_entry = f'{scope_id},{approval_status}' + if shared_entry not in document_item['shared_group_ids']: + document_item['shared_group_ids'].append(shared_entry) + elif source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC: + public_workspace_id = row.get('owner_public_workspace_id') + document_item['public_workspace_id'] = public_workspace_id + document_item['owner_public_workspace_id'] = public_workspace_id + document_item['user_id'] = row.get('user_id') + else: + owner_user_id = row.get('owner_user_id') + document_item['user_id'] = owner_user_id + document_item['owner_user_id'] = owner_user_id + document_item['shared_user_ids'] = _normalize_string_list(row.get('shared_user_ids')) + if access_role == 'shared_user' and scope_id: + shared_entry = f'{scope_id},{approval_status}' + if shared_entry not in document_item['shared_user_ids']: + document_item['shared_user_ids'].append(shared_entry) + + return document_item + + +def _is_backfill_state_ready_for_scope(state, source_scope): + if not isinstance(state, dict): + return False + if _safe_int(state.get('schema_version')) != DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION: + return False + if str(state.get('status') or '').strip().lower() not in DOCUMENT_ACCESS_BACKFILL_READY_STATUSES: + return False + completed_scopes = { + str(scope or '').strip().lower() + for scope in state.get('completed_source_scopes') or [] + } + return source_scope in completed_scopes + + +def _get_document_access_index_readiness(source_scope, settings=None): + normalized_settings = get_document_access_index_settings(settings) + if not normalized_settings.get('container_enabled'): + return { + 'ready': False, + 'status': 'container_disabled', + 'settings': normalized_settings, + } + if not normalized_settings.get('reads_enabled'): + return { + 'ready': False, + 'status': 'reads_disabled', + 'settings': normalized_settings, + } + try: + state = _read_backfill_state() + except Exception as exc: + log_event( + '[DocumentAccessIndex] DAI read path readiness check failed; source document read should be used.', + extra={'source_scope': source_scope, 'error': str(exc)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return { + 'ready': False, + 'status': 'readiness_check_failed', + 'settings': normalized_settings, + } + if not _is_backfill_state_ready_for_scope(state, source_scope): + return { + 'ready': False, + 'status': 'backfill_not_ready', + 'settings': normalized_settings, + 'backfill_status': (state or {}).get('status'), + } + + has_repair_backlog = has_document_access_index_repair_backlog() + if has_repair_backlog is None: + return { + 'ready': False, + 'status': 'repair_backlog_unknown', + 'settings': normalized_settings, + 'backfill_status': state.get('status'), + } + if has_repair_backlog: + return { + 'ready': False, + 'status': 'repair_backlog_present', + 'settings': normalized_settings, + 'backfill_status': state.get('status'), + } + + return { + 'ready': True, + 'status': 'ready', + 'settings': normalized_settings, + 'backfill_status': state.get('status'), + } + + +def query_document_access_index_documents( + source_scope, + user_id=None, + group_ids=None, + public_workspace_id=None, + public_workspace_ids=None, + filters=None, + settings=None, +): + """Return list-ready documents from DAI when the default read path is ready.""" + source_scope = str(source_scope or '').strip().lower() + if source_scope not in DOCUMENT_ACCESS_SOURCE_SCOPES: + return { + 'success': False, + 'status': 'invalid_source_scope', + 'documents': [], + } + + readiness = _get_document_access_index_readiness(source_scope, settings=settings) + if not readiness.get('ready'): + _record_document_access_read_metric( + 'document_list', + source_scope, + readiness.get('status'), + served_from_index=False, + ) + return { + 'success': False, + 'status': readiness.get('status'), + 'documents': [], + 'readiness': readiness, + } + + scope_keys = [ + scope_key + for scope_key in _build_shadow_scope( + source_scope, + user_id=user_id, + group_ids=group_ids, + public_workspace_id=public_workspace_id, + public_workspace_ids=public_workspace_ids, + ) + if scope_key + ] + if not scope_keys: + _record_document_access_read_metric( + 'document_list', + source_scope, + 'missing_scope_keys', + served_from_index=False, + ) + return { + 'success': False, + 'status': 'missing_scope_keys', + 'documents': [], + 'readiness': readiness, + } + + normalized_settings = readiness.get('settings') if isinstance(readiness.get('settings'), dict) else get_document_access_index_settings(settings) + cache_context = _build_document_access_cache_context( + 'document_list', + source_scope, + scope_keys, + {'filters': filters or {}}, + normalized_settings, + ) + cached_payload = _try_get_document_access_cache_entry(cache_context) + if cached_payload is not None: + cached_documents = copy.deepcopy(cached_payload.get('documents') or []) + cache_diagnostics = { + 'label': 'document_access_index_cache_hit', + 'request_charge': 0, + 'elapsed_ms': None, + 'item_count': len(cached_documents), + 'page_count': 0, + 'cache_hit': True, + } + _record_document_access_read_metric( + 'document_list', + source_scope, + 'served_from_cache', + served_from_index=True, + diagnostics=cache_diagnostics, + scope_key_count=len(scope_keys), + served_from_cache=True, + cache_status='hit', + ) + return { + 'success': True, + 'status': 'served_from_cache', + 'documents': cached_documents, + 'scope_keys': copy.deepcopy(cached_payload.get('scope_keys') or scope_keys), + 'diagnostics': cache_diagnostics, + 'readiness': readiness, + 'cache': { + 'hit': True, + 'ttl_seconds': normalized_settings.get('cache_ttl_seconds'), + }, + } + + projection_rows = [] + diagnostics = [] + try: + for scope_key in scope_keys: + scope_rows, scope_diagnostics = _query_candidate_projection_rows_for_scope( + scope_key, + source_scope, + ) + projection_rows.extend(scope_rows) + diagnostics.append(scope_diagnostics) + except Exception as exc: + log_event( + '[DocumentAccessIndex] DAI read path failed; source document read should be used.', + extra={ + 'source_scope': source_scope, + 'scope_key_count': len(scope_keys), + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + _record_document_access_read_metric( + 'document_list', + source_scope, + 'query_failed', + served_from_index=False, + scope_key_count=len(scope_keys), + error=exc, + ) + return { + 'success': False, + 'status': 'query_failed', + 'documents': [], + 'scope_keys': scope_keys, + 'error': str(exc), + 'readiness': readiness, + } + + rows_by_identity = {} + for row in projection_rows: + if not _matches_shadow_filters(row, filters): + continue + identity = _document_family_identity(row, source_scope) + if not identity: + continue + rows_by_identity[identity] = _prefer_projection_row(rows_by_identity.get(identity), row) + + documents = [ + _projection_row_to_document(row, source_scope) + for row in rows_by_identity.values() + ] + combined_diagnostics = _combine_query_diagnostics('document_access_index_read', diagnostics) + _record_document_access_read_metric( + 'document_list', + source_scope, + 'served_from_index', + served_from_index=True, + diagnostics=combined_diagnostics, + scope_key_count=len(scope_keys), + ) + _try_set_document_access_cache_entry( + cache_context, + { + 'documents': documents, + 'scope_keys': scope_keys, + }, + ) + return { + 'success': True, + 'status': 'served_from_index', + 'documents': documents, + 'scope_keys': scope_keys, + 'diagnostics': combined_diagnostics, + 'readiness': readiness, + 'cache': { + 'hit': False, + 'ttl_seconds': normalized_settings.get('cache_ttl_seconds'), + }, + } + + +def _projection_rows_to_tag_counts(rows, source_scope): + owner_access_roles = {'owner'} + if source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC: + owner_access_roles.add('public_workspace') + + rows_by_identity = {} + for row in rows or []: + access_role = str(row.get('access_role') or '').strip().lower() + if access_role not in owner_access_roles: + continue + identity = _document_family_identity(row, source_scope) + if not identity: + continue + rows_by_identity[identity] = _prefer_projection_row(rows_by_identity.get(identity), row) + + tag_counts = {} + for row in rows_by_identity.values(): + for tag in _normalize_string_list(row.get('tags')): + normalized_tag = _normalize_filter_text(tag) + if normalized_tag: + tag_counts[normalized_tag] = tag_counts.get(normalized_tag, 0) + 1 + + return tag_counts + + +def _merge_tag_counts(tag_count_items): + merged_counts = {} + for tag_counts in tag_count_items or []: + for tag_name, count in (tag_counts or {}).items(): + normalized_tag = _normalize_filter_text(tag_name) + if not normalized_tag: + continue + merged_counts[normalized_tag] = merged_counts.get(normalized_tag, 0) + _safe_int(count) + return merged_counts + + +def query_document_access_index_tag_counts( + source_scope, + user_id=None, + group_ids=None, + public_workspace_id=None, + public_workspace_ids=None, + settings=None, +): + """Return document tag counts from DAI when the default read path is ready.""" + source_scope = str(source_scope or '').strip().lower() + if source_scope not in DOCUMENT_ACCESS_SOURCE_SCOPES: + return { + 'success': False, + 'status': 'invalid_source_scope', + 'tag_counts': {}, + 'tag_counts_by_scope_key': {}, + } + + readiness = _get_document_access_index_readiness(source_scope, settings=settings) + if not readiness.get('ready'): + _record_document_access_read_metric( + 'tag_list', + source_scope, + readiness.get('status'), + served_from_index=False, + ) + return { + 'success': False, + 'status': readiness.get('status'), + 'tag_counts': {}, + 'tag_counts_by_scope_key': {}, + 'readiness': readiness, + } + + scope_keys = [ + scope_key + for scope_key in _build_shadow_scope( + source_scope, + user_id=user_id, + group_ids=group_ids, + public_workspace_id=public_workspace_id, + public_workspace_ids=public_workspace_ids, + ) + if scope_key + ] + if not scope_keys: + _record_document_access_read_metric( + 'tag_list', + source_scope, + 'missing_scope_keys', + served_from_index=False, + ) + return { + 'success': False, + 'status': 'missing_scope_keys', + 'tag_counts': {}, + 'tag_counts_by_scope_key': {}, + 'readiness': readiness, + } + + normalized_settings = readiness.get('settings') if isinstance(readiness.get('settings'), dict) else get_document_access_index_settings(settings) + cache_context = _build_document_access_cache_context( + 'tag_list', + source_scope, + scope_keys, + {}, + normalized_settings, + ) + cached_payload = _try_get_document_access_cache_entry(cache_context) + if cached_payload is not None: + cached_tag_counts = copy.deepcopy(cached_payload.get('tag_counts') or {}) + cached_tag_counts_by_scope_key = copy.deepcopy(cached_payload.get('tag_counts_by_scope_key') or {}) + cache_diagnostics = { + 'label': 'document_access_index_tag_cache_hit', + 'request_charge': 0, + 'elapsed_ms': None, + 'item_count': len(cached_tag_counts), + 'page_count': 0, + 'cache_hit': True, + } + _record_document_access_read_metric( + 'tag_list', + source_scope, + 'served_from_cache', + served_from_index=True, + diagnostics=cache_diagnostics, + scope_key_count=len(scope_keys), + served_from_cache=True, + cache_status='hit', + ) + return { + 'success': True, + 'status': 'served_from_cache', + 'tag_counts': cached_tag_counts, + 'tag_counts_by_scope_key': cached_tag_counts_by_scope_key, + 'scope_keys': copy.deepcopy(cached_payload.get('scope_keys') or scope_keys), + 'readiness': readiness, + 'cache': { + 'hit': True, + 'ttl_seconds': normalized_settings.get('cache_ttl_seconds'), + }, + } + + diagnostics = [] + tag_counts_by_scope_key = {} + try: + for scope_key in scope_keys: + scope_rows, scope_diagnostics = _query_tag_projection_rows_for_scope( + scope_key, + source_scope, + ) + tag_counts_by_scope_key[scope_key] = _projection_rows_to_tag_counts( + scope_rows, + source_scope, + ) + diagnostics.append(scope_diagnostics) + except Exception as exc: + log_event( + '[DocumentAccessIndex] DAI tag read path failed; source document tag read should be used.', + extra={ + 'source_scope': source_scope, + 'scope_key_count': len(scope_keys), + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + _record_document_access_read_metric( + 'tag_list', + source_scope, + 'query_failed', + served_from_index=False, + scope_key_count=len(scope_keys), + error=exc, + ) + return { + 'success': False, + 'status': 'query_failed', + 'tag_counts': {}, + 'tag_counts_by_scope_key': {}, + 'scope_keys': scope_keys, + 'error': str(exc), + 'readiness': readiness, + } + + combined_diagnostics = _combine_query_diagnostics('document_access_index_tag_read', diagnostics) + _record_document_access_read_metric( + 'tag_list', + source_scope, + 'served_from_index', + served_from_index=True, + diagnostics=combined_diagnostics, + scope_key_count=len(scope_keys), + ) + result = { + 'success': True, + 'status': 'served_from_index', + 'tag_counts': _merge_tag_counts(tag_counts_by_scope_key.values()), + 'tag_counts_by_scope_key': tag_counts_by_scope_key, + 'scope_keys': scope_keys, + 'cache': { + 'hit': False, + 'ttl_seconds': normalized_settings.get('cache_ttl_seconds'), + }, + } + _try_set_document_access_cache_entry( + cache_context, + { + 'tag_counts': result['tag_counts'], + 'tag_counts_by_scope_key': tag_counts_by_scope_key, + 'scope_keys': scope_keys, + }, + ) + return result + + +def query_document_access_index_legacy_count( + source_scope, + user_id=None, + group_ids=None, + public_workspace_id=None, + public_workspace_ids=None, + settings=None, +): + """Return the unfiltered owner-scope legacy document count from DAI.""" + source_scope = str(source_scope or '').strip().lower() + if source_scope not in DOCUMENT_ACCESS_SOURCE_SCOPES: + return { + 'success': False, + 'status': 'invalid_source_scope', + 'legacy_count': 0, + } + + readiness = _get_document_access_index_readiness(source_scope, settings=settings) + if not readiness.get('ready'): + return { + 'success': False, + 'status': readiness.get('status'), + 'legacy_count': 0, + 'readiness': readiness, + } + + scope_keys = [ + scope_key + for scope_key in _build_shadow_scope( + source_scope, + user_id=user_id, + group_ids=group_ids, + public_workspace_id=public_workspace_id, + public_workspace_ids=public_workspace_ids, + ) + if scope_key + ] + if not scope_keys: + return { + 'success': False, + 'status': 'missing_scope_keys', + 'legacy_count': 0, + 'readiness': readiness, + } + + normalized_settings = readiness.get('settings') if isinstance(readiness.get('settings'), dict) else get_document_access_index_settings(settings) + access_role = 'public_workspace' if source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC else 'owner' + cache_context = _build_document_access_cache_context( + 'legacy_count', + source_scope, + scope_keys, + {'access_role': access_role}, + normalized_settings, + ) + cached_payload = _try_get_document_access_cache_entry(cache_context) + if cached_payload is not None: + return { + 'success': True, + 'status': 'served_from_cache', + 'legacy_count': _safe_int(cached_payload.get('legacy_count')), + 'scope_keys': copy.deepcopy(cached_payload.get('scope_keys') or scope_keys), + 'readiness': readiness, + 'cache': { + 'hit': True, + 'ttl_seconds': normalized_settings.get('cache_ttl_seconds'), + }, + } + + legacy_identities = set() + try: + for scope_key in scope_keys: + for row in _query_legacy_projection_rows_for_scope(scope_key, source_scope, access_role): + identity = _document_identity(row) + if identity: + legacy_identities.add(identity) + except Exception as exc: + log_event( + '[DocumentAccessIndex] DAI legacy count query failed; legacy update prompt should use safe default.', + extra={ + 'source_scope': source_scope, + 'scope_key_count': len(scope_keys), + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return { + 'success': False, + 'status': 'query_failed', + 'legacy_count': 0, + 'scope_keys': scope_keys, + 'error': str(exc), + 'readiness': readiness, + } + + result = { + 'success': True, + 'status': 'served_from_index', + 'legacy_count': len(legacy_identities), + 'scope_keys': scope_keys, + 'readiness': readiness, + 'cache': { + 'hit': False, + 'ttl_seconds': normalized_settings.get('cache_ttl_seconds'), + }, + } + _try_set_document_access_cache_entry( + cache_context, + { + 'legacy_count': result['legacy_count'], + 'scope_keys': scope_keys, + }, + ) + return result + + +def _matches_shadow_filters(row, filters): + filters = filters or {} + array_match_mode = str(filters.get('array_match_mode') or 'contains').strip().lower() + + def array_matches(items, filter_value): + if array_match_mode == 'exact': + raw_filter = str(filter_value or '').strip() + if not raw_filter: + return True + raw_items = [str(item or '').strip() for item in items or []] + return any(item == raw_filter for item in raw_items) + + normalized_filter = _normalize_filter_text(filter_value) + if not normalized_filter: + return True + normalized_items = [_normalize_filter_text(item) for item in items or []] + return any(normalized_filter in item for item in normalized_items) + + search_term = _normalize_filter_text(filters.get('search')) + if search_term: + searchable_text = ' '.join([ + _normalize_filter_text(row.get('file_name')), + _normalize_filter_text(row.get('title')), + ]) + if search_term not in searchable_text: + return False + + classification_filter_raw = str(filters.get('classification') or '').strip() + if classification_filter_raw: + classification_filter = _normalize_filter_text(classification_filter_raw) + classification = str(row.get('document_classification') or '').strip() + if classification_filter == 'none': + normalized_classification = _normalize_filter_text(classification) + none_values = {'', 'none'} if filters.get('classification_none_matches_literal') else {''} + if normalized_classification not in none_values: + return False + elif classification != classification_filter_raw: + return False + + if not array_matches(row.get('authors'), filters.get('author')): + return False + + if not array_matches(row.get('keywords'), filters.get('keywords')): + return False + + abstract_filter = _normalize_filter_text(filters.get('abstract')) + if abstract_filter and abstract_filter not in _normalize_filter_text(row.get('abstract')): + return False + + tag_filters = [ + _normalize_filter_text(tag) + for tag in filters.get('tags') or [] + if _normalize_filter_text(tag) + ] + if tag_filters: + row_tags = {_normalize_filter_text(tag) for tag in row.get('tags') or []} + if not all(tag in row_tags for tag in tag_filters): + return False + + return True + + +def _build_shadow_scope(source_scope, user_id=None, group_ids=None, public_workspace_id=None, public_workspace_ids=None): + if source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC: + workspace_ids = list(public_workspace_ids or []) + if not workspace_ids and public_workspace_id: + workspace_ids = [public_workspace_id] + return [ + build_document_access_scope_key(DOCUMENT_ACCESS_SCOPE_PUBLIC, workspace_id) + for workspace_id in workspace_ids + ] + if source_scope == DOCUMENT_ACCESS_SCOPE_GROUP: + return [ + build_document_access_scope_key(DOCUMENT_ACCESS_SCOPE_GROUP, group_id) + for group_id in list(group_ids or []) + ] + return [build_document_access_scope_key(DOCUMENT_ACCESS_PRINCIPAL_USER, user_id)] + + +def validate_document_access_index_shadow( + authoritative_documents, + source_scope, + user_id=None, + group_ids=None, + public_workspace_id=None, + public_workspace_ids=None, + filters=None, + source_query_metrics=None, + settings=None, + context='document_list', +): + """Compare source document-list results with projection rows without changing reads.""" + normalized_settings = get_document_access_index_settings(settings) + source_scope = str(source_scope or '').strip().lower() + if not normalized_settings.get('container_enabled') or not normalized_settings.get('shadow_validation_enabled'): + return { + 'success': True, + 'status': 'skipped_disabled', + 'context': context, + } + if source_scope not in DOCUMENT_ACCESS_SOURCE_SCOPES: + return { + 'success': False, + 'status': 'skipped_invalid_scope', + 'context': context, + } + + scope_keys = [ + scope_key + for scope_key in _build_shadow_scope( + source_scope, + user_id=user_id, + group_ids=group_ids, + public_workspace_id=public_workspace_id, + public_workspace_ids=public_workspace_ids, + ) + if scope_key + ] + if not scope_keys: + return { + 'success': False, + 'status': 'skipped_missing_scope', + 'context': context, + } + + projection_query_metrics = None + candidate_read_metrics = None + try: + authoritative_ids = { + identity + for identity in ( + _document_family_identity(document_item, source_scope) + for document_item in authoritative_documents or [] + ) + if identity + } + projection_rows = [] + projection_scope_diagnostics = [] + for scope_key in scope_keys: + scope_rows, scope_diagnostics = _query_projection_rows_for_scope_with_diagnostics(scope_key, source_scope) + projection_rows.extend(scope_rows) + projection_scope_diagnostics.append(scope_diagnostics) + projection_query_metrics = _combine_query_diagnostics( + 'projection', + projection_scope_diagnostics, + ) + candidate_read_metrics = _collect_candidate_read_metrics( + scope_keys, + source_scope, + ) + + projection_ids = { + identity + for identity in ( + _document_family_identity(row, source_scope) + for row in projection_rows + if _matches_shadow_filters(row, filters) + ) + if identity + } + missing_from_projection = sorted(authoritative_ids - projection_ids) + extra_in_projection = sorted(projection_ids - authoritative_ids) + status = 'matched' if not missing_from_projection and not extra_in_projection else 'mismatch' + result = { + 'success': status == 'matched', + 'status': status, + 'context': context, + 'source_scope': source_scope, + 'scope_keys': scope_keys, + 'authoritative_count': len(authoritative_ids), + 'projection_count': len(projection_ids), + 'missing_count': len(missing_from_projection), + 'extra_count': len(extra_in_projection), + 'missing_sample': missing_from_projection[:DOCUMENT_ACCESS_SHADOW_MAX_SAMPLE_IDS], + 'extra_sample': extra_in_projection[:DOCUMENT_ACCESS_SHADOW_MAX_SAMPLE_IDS], + 'checked_at': _utc_now_iso(), + } + result.update(_build_shadow_metric_fields( + source_query_metrics, + projection_query_metrics, + candidate_read_metrics, + )) + _write_shadow_validation_state(result) + log_level = logging.INFO if status == 'matched' else logging.WARNING + log_event( + '[DocumentAccessIndex] Shadow validation completed.', + extra=result, + level=log_level, + ) + return result + except Exception as exc: + result = { + 'success': False, + 'status': 'error', + 'context': context, + 'source_scope': source_scope, + 'scope_keys': scope_keys, + 'error': str(exc), + 'checked_at': _utc_now_iso(), + } + result.update(_build_shadow_metric_fields( + source_query_metrics, + projection_query_metrics, + candidate_read_metrics, + )) + try: + _write_shadow_validation_state(result) + except Exception as state_error: + log_event( + '[DocumentAccessIndex] Failed to persist shadow validation error state.', + extra={'error': str(state_error), 'shadow_error': str(exc)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + log_event( + '[DocumentAccessIndex] Shadow validation failed; source document results remain authoritative.', + extra=result, + level=logging.WARNING, + exceptionTraceback=True, + ) + return result + + +def _repair_document_id(source_scope, document_id): + raw_id = f'document_access_projection_repair:{source_scope}:{document_id}' + if len(raw_id) <= 900: + return raw_id + return f'document_access_projection_repair:{hashlib.sha256(raw_id.encode("utf-8")).hexdigest()}' + + +def _record_projection_repair_required(document_item, operation, error): + document_item = document_item if isinstance(document_item, dict) else {} + document_id = str(document_item.get('id') or document_item.get('document_id') or '').strip() + if not document_id: + return None + source_scope = _resolve_source_scope(document_item) + repair_doc_id = _repair_document_id(source_scope, document_id) + repair_doc = { + 'id': repair_doc_id, + 'type': DOCUMENT_ACCESS_REPAIR_TYPE, + 'status': 'repair_required', + 'operation': operation, + 'source_scope': source_scope, + 'source_document_id': document_id, + 'source_updated_at': document_item.get('last_updated') or document_item.get('upload_date'), + 'error': str(error), + 'updated_at': _utc_now_iso(), + 'schema_version': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION, + } + cache_scope_keys = _cache_scope_keys_from_exception(error) + if cache_scope_keys: + repair_doc['cache_scope_keys'] = cache_scope_keys + try: + cosmos_settings_container.upsert_item(repair_doc) + except Exception: + _try_write_repair_backlog_state( + True, + reason=f'{operation}_repair_doc_write_failed', + repair_tracking_failed=True, + ) + raise + + if _try_write_repair_backlog_state(True, reason=operation) is None: + _try_delete_repair_backlog_state(reason=f'{operation}_repair_state_untrusted') + return repair_doc + + +def _clear_projection_repair_required(source_scope, document_id): + repair_doc_id = _repair_document_id(source_scope, document_id) + try: + cosmos_settings_container.delete_item(item=repair_doc_id, partition_key=repair_doc_id) + except Exception as exc: + if _is_not_found_error(exc): + pass + else: + log_event( + '[DocumentAccessIndex] Failed to clear projection repair state.', + extra={ + 'source_scope': source_scope, + 'document_id': document_id, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return + + try: + has_backlog = _query_repair_backlog_exists() + except Exception as exc: + log_event( + '[DocumentAccessIndex] Failed to refresh projection repair backlog state after repair clear.', + extra={ + 'source_scope': source_scope, + 'document_id': document_id, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return + + if not has_backlog: + try: + state = _read_repair_backlog_state() + except Exception as exc: + log_event( + '[DocumentAccessIndex] Failed to verify repair backlog state after repair clear.', + extra={ + 'source_scope': source_scope, + 'document_id': document_id, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return + if _repair_backlog_state_has_untracked_failure(state): + return + + updated_state = _try_write_repair_backlog_state(has_backlog, reason='repair_cleared') + if updated_state is None and not has_backlog: + _try_delete_repair_backlog_state(reason='repair_cleared_write_failed') + + +def sync_document_access_index_for_document( + document_item, + operation=DOCUMENT_ACCESS_OPERATION_UPSERT, + settings=None, + force=False, +): + """Synchronize access-index rows for one source document.""" + if not _is_write_through_enabled(settings, force=force): + return { + 'success': True, + 'status': 'skipped_disabled', + 'operation': operation, + 'upserted_count': 0, + 'deleted_count': 0, + } + + rows = build_document_access_index_rows(document_item) + document_id = str((document_item or {}).get('id') or (document_item or {}).get('document_id') or '').strip() + if not document_id: + raise ValueError('document_item must include id or document_id') + + source_scope = _resolve_source_scope(document_item) + expected_keys = {(row['scope_key'], row['id']) for row in rows} + existing_rows = _query_existing_projection_rows(source_scope, document_id) + affected_scope_keys = sorted({ + row.get('scope_key') + for row in rows + existing_rows + if row.get('scope_key') + }) + deleted_count = 0 + + try: + for existing_row in existing_rows: + existing_key = (existing_row.get('scope_key'), existing_row.get('id')) + if existing_key in expected_keys: + continue + cosmos_document_access_index_container.delete_item( + item=existing_row.get('id'), + partition_key=existing_row.get('scope_key'), + ) + deleted_count += 1 + + for row in rows: + cosmos_document_access_index_container.upsert_item(copy.deepcopy(row)) + + _clear_projection_repair_required(source_scope, document_id) + invalidation_result = invalidate_document_access_index_cache_scope_keys( + affected_scope_keys, + reason=operation, + settings=settings, + ) + _raise_for_failed_cache_invalidation(invalidation_result, operation, affected_scope_keys) + except DocumentAccessIndexCacheInvalidationError: + raise + except Exception as exc: + raise DocumentAccessIndexProjectionMutationError(operation, exc, affected_scope_keys) from exc + + return { + 'success': True, + 'status': 'synchronized', + 'operation': operation, + 'source_scope': source_scope, + 'document_id': document_id, + 'upserted_count': len(rows), + 'deleted_count': deleted_count, + } + + +def delete_document_access_index_for_document( + document_item, + operation=DOCUMENT_ACCESS_OPERATION_DELETE, + settings=None, + force=False, +): + """Delete all access-index rows for one source document.""" + if not _is_write_through_enabled(settings, force=force): + return { + 'success': True, + 'status': 'skipped_disabled', + 'operation': operation, + 'deleted_count': 0, + } + + document_id = str((document_item or {}).get('id') or (document_item or {}).get('document_id') or '').strip() + if not document_id: + raise ValueError('document_item must include id or document_id') + + source_scope = _resolve_source_scope(document_item) + existing_rows = _query_existing_projection_rows(source_scope, document_id) + affected_scope_keys = sorted({ + existing_row.get('scope_key') + for existing_row in existing_rows + if existing_row.get('scope_key') + }) or _document_access_scope_keys_from_document_item(document_item) + deleted_count = 0 + try: + for existing_row in existing_rows: + cosmos_document_access_index_container.delete_item( + item=existing_row.get('id'), + partition_key=existing_row.get('scope_key'), + ) + deleted_count += 1 + + _clear_projection_repair_required(source_scope, document_id) + invalidation_result = invalidate_document_access_index_cache_scope_keys( + affected_scope_keys, + reason=operation, + settings=settings, + ) + _raise_for_failed_cache_invalidation(invalidation_result, operation, affected_scope_keys) + except DocumentAccessIndexCacheInvalidationError: + raise + except Exception as exc: + raise DocumentAccessIndexProjectionMutationError(operation, exc, affected_scope_keys) from exc + + return { + 'success': True, + 'status': 'deleted', + 'operation': operation, + 'source_scope': source_scope, + 'document_id': document_id, + 'deleted_count': deleted_count, + } + + +def sync_document_access_index_for_document_fail_open(document_item, operation=DOCUMENT_ACCESS_OPERATION_UPSERT, settings=None): + """Synchronize projection rows without failing the source document mutation.""" + try: + return sync_document_access_index_for_document(document_item, operation=operation, settings=settings) + except Exception as exc: + cache_scope_keys = ( + _cache_scope_keys_from_exception(exc) + or _document_access_scope_keys_from_document_item(document_item) + ) + invalidate_document_access_index_cache_scope_keys( + cache_scope_keys, + reason=f'{operation}_failed', + settings=settings, + ) + log_event( + '[DocumentAccessIndex] Document access index synchronization failed; source document remains authoritative.', + extra={ + 'document_id': (document_item or {}).get('id'), + 'operation': operation, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + try: + _record_projection_repair_required(document_item, operation, exc) + except Exception as repair_exc: + log_event( + '[DocumentAccessIndex] Failed to record projection repair state.', + extra={ + 'document_id': (document_item or {}).get('id'), + 'operation': operation, + 'error': str(repair_exc), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return { + 'success': False, + 'status': 'repair_required', + 'operation': operation, + 'error': str(exc), + } + + +def delete_document_access_index_for_document_fail_open(document_item, operation=DOCUMENT_ACCESS_OPERATION_DELETE, settings=None): + """Delete projection rows without failing the source document mutation.""" + try: + return delete_document_access_index_for_document(document_item, operation=operation, settings=settings) + except Exception as exc: + cache_scope_keys = ( + _cache_scope_keys_from_exception(exc) + or _document_access_scope_keys_from_document_item(document_item) + ) + invalidate_document_access_index_cache_scope_keys( + cache_scope_keys, + reason=f'{operation}_failed', + settings=settings, + ) + log_event( + '[DocumentAccessIndex] Document access index delete failed; source document remains authoritative.', + extra={ + 'document_id': (document_item or {}).get('id'), + 'operation': operation, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + try: + _record_projection_repair_required(document_item, operation, exc) + except Exception as repair_exc: + log_event( + '[DocumentAccessIndex] Failed to record projection delete repair state.', + extra={ + 'document_id': (document_item or {}).get('id'), + 'operation': operation, + 'error': str(repair_exc), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return { + 'success': False, + 'status': 'repair_required', + 'operation': operation, + 'error': str(exc), + } + + +def _write_repair_backlog_state( + has_repair_backlog, + repair_required_count=None, + reason=None, + repair_tracking_failed=False, +): + state_doc = { + 'id': DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, + 'type': DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_TYPE, + 'has_repair_backlog': bool(has_repair_backlog), + 'updated_at': _utc_now_iso(), + 'schema_version': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION, + } + if repair_required_count is not None: + state_doc['repair_required_count'] = _safe_int(repair_required_count) + if reason: + state_doc['reason'] = str(reason) + if repair_tracking_failed: + state_doc['repair_tracking_failed'] = True + cosmos_settings_container.upsert_item(state_doc) + _set_cached_document_access_state(DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, state_doc) + return state_doc + + +def _try_write_repair_backlog_state( + has_repair_backlog, + repair_required_count=None, + reason=None, + repair_tracking_failed=False, +): + try: + return _write_repair_backlog_state( + has_repair_backlog, + repair_required_count=repair_required_count, + reason=reason, + repair_tracking_failed=repair_tracking_failed, + ) + except Exception as exc: + log_event( + '[DocumentAccessIndex] Failed to update projection repair backlog state.', + extra={ + 'has_repair_backlog': bool(has_repair_backlog), + 'repair_required_count': repair_required_count, + 'reason': reason, + 'repair_tracking_failed': bool(repair_tracking_failed), + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + +def _try_delete_repair_backlog_state(reason=None): + try: + cosmos_settings_container.delete_item( + item=DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, + partition_key=DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, + ) + _set_cached_document_access_state(DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, None) + return True + except Exception as exc: + if _is_not_found_error(exc): + _set_cached_document_access_state(DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, None) + return True + log_event( + '[DocumentAccessIndex] Failed to delete projection repair backlog state.', + extra={ + 'reason': reason, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return False + + +def _read_repair_backlog_state(use_cache=True): + if use_cache: + cache_hit, cached_state = _get_cached_document_access_state(DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID) + if cache_hit: + return cached_state + try: + state = cosmos_settings_container.read_item( + item=DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, + partition_key=DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, + ) + except Exception as exc: + if _is_not_found_error(exc): + _set_cached_document_access_state(DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, None) + return None + raise + if not isinstance(state, dict) or state.get('type') != DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_TYPE: + _set_cached_document_access_state(DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, None) + return None + if not isinstance(state.get('has_repair_backlog'), bool): + _set_cached_document_access_state(DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, None) + return None + _set_cached_document_access_state(DOCUMENT_ACCESS_REPAIR_BACKLOG_STATE_DOC_ID, state) + return state + + +def _repair_backlog_state_has_untracked_failure(state): + return ( + isinstance(state, dict) + and bool(state.get('has_repair_backlog')) + and bool(state.get('repair_tracking_failed')) + ) + + +def _query_repair_backlog_exists(): + query = ( + 'SELECT TOP 1 VALUE c.id FROM c ' + 'WHERE c.type = @type AND c.status = @status' + ) + repair_ids = list(cosmos_settings_container.query_items( + query=query, + parameters=[ + {'name': '@type', 'value': DOCUMENT_ACCESS_REPAIR_TYPE}, + {'name': '@status', 'value': 'repair_required'}, + ], + enable_cross_partition_query=True, + max_item_count=1, + )) + return bool(repair_ids) + + +def _refresh_repair_backlog_state_from_query(reason=None): + existing_state = _read_repair_backlog_state(use_cache=False) + has_backlog = _query_repair_backlog_exists() + if not has_backlog and _repair_backlog_state_has_untracked_failure(existing_state): + return True + _write_repair_backlog_state(has_backlog, reason=reason or 'repair_backlog_query') + return has_backlog + + +def _get_source_scope_container(source_scope): + if source_scope == DOCUMENT_ACCESS_SCOPE_GROUP: + return cosmos_group_documents_container, cosmos_group_documents_container_name + if source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC: + return cosmos_public_documents_container, cosmos_public_documents_container_name + return cosmos_user_documents_container, cosmos_user_documents_container_name + + +def _normalize_source_scopes(source_scopes=None): + if not source_scopes: + return list(DOCUMENT_ACCESS_SOURCE_SCOPES) + if isinstance(source_scopes, str): + source_scopes = [source_scopes] + normalized_scopes = [] + for source_scope in list(source_scopes or []): + normalized_scope = str(source_scope or '').strip().lower() + if normalized_scope in DOCUMENT_ACCESS_SOURCE_SCOPES and normalized_scope not in normalized_scopes: + normalized_scopes.append(normalized_scope) + return normalized_scopes or list(DOCUMENT_ACCESS_SOURCE_SCOPES) + + +def _query_first_page(container, query, parameters=None, continuation_token=None, max_item_count=100): + query_iterable = container.query_items( + query=query, + parameters=list(parameters or []), + enable_cross_partition_query=True, + max_item_count=max_item_count, + ) + if not hasattr(query_iterable, 'by_page'): + return list(query_iterable), None + + try: + page_iterator = query_iterable.by_page(continuation_token=continuation_token) + except TypeError: + page_iterator = query_iterable.by_page(continuation_token) + try: + page = next(page_iterator) + except StopIteration: + return [], None + + return list(page), getattr(page_iterator, 'continuation_token', None) + + +def _query_source_document_page(source_scope, continuation_token=None, max_item_count=100): + source_container, source_container_name = _get_source_scope_container(source_scope) + query = ( + 'SELECT * FROM c ' + 'WHERE (NOT IS_DEFINED(c.type) OR c.type = @document_metadata_type)' + ) + documents, next_token = _query_first_page( + source_container, + query=query, + parameters=[{'name': '@document_metadata_type', 'value': 'document_metadata'}], + continuation_token=continuation_token, + max_item_count=max_item_count, + ) + return { + 'source_scope': source_scope, + 'source_container': source_container_name, + 'documents': documents, + 'continuation_token': next_token, + } + + +def _query_repair_documents(max_item_count=DOCUMENT_ACCESS_DEFAULT_REPAIR_BATCH_SIZE): + query = ( + 'SELECT * FROM c ' + 'WHERE c.type = @type AND c.status = @status' + ) + repair_docs, _next_token = _query_first_page( + cosmos_settings_container, + query=query, + parameters=[ + {'name': '@type', 'value': DOCUMENT_ACCESS_REPAIR_TYPE}, + {'name': '@status', 'value': 'repair_required'}, + ], + max_item_count=max_item_count, + ) + return repair_docs + + +def _read_backfill_state(use_cache=True): + if use_cache: + cache_hit, cached_state = _get_cached_document_access_state(DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID) + if cache_hit: + return cached_state + try: + state = cosmos_settings_container.read_item( + item=DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID, + partition_key=DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID, + ) + except Exception as exc: + if _is_not_found_error(exc): + _set_cached_document_access_state(DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID, None) + return None + log_event( + '[DocumentAccessIndex] Failed to read document access index backfill state.', + extra={'error': str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + raise + _set_cached_document_access_state(DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID, state) + return state + + +def _write_backfill_state(state): + state_body = copy.deepcopy(state or {}) + state_body.update({ + 'id': DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID, + 'type': DOCUMENT_ACCESS_BACKFILL_STATE_TYPE, + 'updated_at': _utc_now_iso(), + 'schema_version': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION, + }) + cosmos_settings_container.upsert_item(state_body) + _set_cached_document_access_state(DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID, state_body) + return state_body + + +def _build_initial_backfill_state(source_scopes): + return { + 'id': DOCUMENT_ACCESS_BACKFILL_STATE_DOC_ID, + 'type': DOCUMENT_ACCESS_BACKFILL_STATE_TYPE, + 'status': 'not_started', + 'source_scopes': list(source_scopes), + 'completed_source_scopes': [], + 'continuation_tokens': {}, + 'current_source_scope': None, + 'total_documents_processed': 0, + 'total_documents_failed': 0, + 'total_rows_upserted': 0, + 'total_rows_deleted': 0, + 'last_error': None, + 'schema_version': DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION, + } + + +def _get_next_backfill_scope(source_scopes, completed_source_scopes): + completed = set(completed_source_scopes or []) + for source_scope in source_scopes: + if source_scope not in completed: + return source_scope + return None + + +def _repair_document_stub(source_scope, document_id): + document_stub = {'id': document_id} + if source_scope == DOCUMENT_ACCESS_SCOPE_GROUP: + document_stub['group_id'] = 'repair' + elif source_scope == DOCUMENT_ACCESS_SCOPE_PUBLIC: + document_stub['public_workspace_id'] = 'repair' + return document_stub + + +def _read_source_document_for_repair(source_scope, document_id): + source_container, _source_container_name = _get_source_scope_container(source_scope) + return source_container.read_item(item=document_id, partition_key=document_id) + + +def _repair_operation_represents_delete(operation): + normalized_operation = str(operation or '').strip().lower() + return 'delete' in normalized_operation or 'cleanup' in normalized_operation + + +def _mark_repair_document_failed(repair_doc, error): + updated_repair_doc = copy.deepcopy(repair_doc or {}) + updated_repair_doc.update({ + 'status': 'repair_required', + 'last_repair_error': str(error), + 'last_repair_attempted_at': _utc_now_iso(), + 'repair_attempt_count': int(updated_repair_doc.get('repair_attempt_count') or 0) + 1, + }) + cosmos_settings_container.upsert_item(updated_repair_doc) + return updated_repair_doc + + +def count_document_access_index_repair_documents(): + """Return the current number of projection repair documents.""" + query = ( + 'SELECT VALUE COUNT(1) FROM c ' + 'WHERE c.type = @type AND c.status = @status' + ) + try: + count_items = list(cosmos_settings_container.query_items( + query=query, + parameters=[ + {'name': '@type', 'value': DOCUMENT_ACCESS_REPAIR_TYPE}, + {'name': '@status', 'value': 'repair_required'}, + ], + enable_cross_partition_query=True, + max_item_count=1, + )) + repair_count = _safe_int(count_items[0]) if count_items else 0 + if repair_count <= 0: + state = _read_repair_backlog_state() + if _repair_backlog_state_has_untracked_failure(state): + return None + _try_write_repair_backlog_state(False, repair_required_count=0, reason='repair_count') + return 0 + _try_write_repair_backlog_state( + repair_count > 0, + repair_required_count=repair_count, + reason='repair_count', + ) + return repair_count + except Exception as exc: + log_event( + '[DocumentAccessIndex] Failed to count projection repair documents.', + extra={'error': str(exc)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + +def has_document_access_index_repair_backlog(): + """Return whether any projection repair record is currently pending.""" + try: + state = _read_repair_backlog_state() + if state is not None: + if state.get('has_repair_backlog'): + return True + has_backlog = _query_repair_backlog_exists() + if has_backlog: + _try_write_repair_backlog_state(True, reason='repair_backlog_false_state_corrected') + return has_backlog + return _refresh_repair_backlog_state_from_query(reason='repair_backlog_state_initialize') + except Exception as exc: + log_event( + '[DocumentAccessIndex] Failed to check projection repair backlog.', + extra={'error': str(exc)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + +def _build_document_access_index_maintenance_summary(state, repair_required_count, normalized_settings): + state = state if isinstance(state, dict) else {} + normalized_status = str(state.get('status') or 'not_started').strip().lower() or 'not_started' + repair_count_known = repair_required_count is not None + has_repair_work = repair_count_known and int(repair_required_count or 0) > 0 + has_unknown_repair_work = not repair_count_known + has_backfill_work = normalized_status not in DOCUMENT_ACCESS_BACKFILL_COMPLETE_STATUSES + + if not normalized_settings.get('container_enabled'): + next_action = 'disabled' + elif has_unknown_repair_work: + next_action = 'check_repair_backlog' + elif has_repair_work: + next_action = 'repair' + elif has_backfill_work: + next_action = 'backfill' + else: + next_action = 'monitor' + + return { + 'mode': 'automatic', + 'auto_maintenance_enabled': bool(normalized_settings.get('container_enabled')), + 'has_more_work': bool(normalized_settings.get('container_enabled') and ( + has_unknown_repair_work or has_repair_work or has_backfill_work + )), + 'next_action': next_action, + 'backfill_status': normalized_status, + 'repair_required_count': repair_required_count, + 'last_batch_processed_count': _safe_int(state.get('last_processed_count')), + 'last_batch_failed_count': _safe_int(state.get('last_failed_count')), + 'last_batch_rows_upserted': _safe_int(state.get('last_rows_upserted')), + 'last_batch_rows_deleted': _safe_int(state.get('last_rows_deleted')), + 'last_completed_at': state.get('last_completed_at'), + 'last_error': state.get('last_error'), + } + + +def is_document_access_index_maintenance_pending(status_payload): + """Return True when DAI repair/backfill work should keep active maintenance polling.""" + if not isinstance(status_payload, dict): + return False + backfill_status = status_payload.get('document_access_index_backfill') + if isinstance(backfill_status, dict): + status_payload = backfill_status + maintenance_summary = status_payload.get('maintenance') + if isinstance(maintenance_summary, dict) and 'has_more_work' in maintenance_summary: + return bool(maintenance_summary.get('has_more_work')) + + state = status_payload.get('state') if isinstance(status_payload.get('state'), dict) else {} + repair_required_count = status_payload.get('repair_required_count') + settings = status_payload.get('settings') if isinstance(status_payload.get('settings'), dict) else {} + return _build_document_access_index_maintenance_summary( + state, + repair_required_count, + { + 'container_enabled': settings.get('container_enabled', True), + }, + ).get('has_more_work') + + +def get_document_access_index_backfill_status(settings=None): + """Return persisted backfill state and repair backlog diagnostics.""" + normalized_settings = get_document_access_index_settings(settings) + state = _read_backfill_state() or _build_initial_backfill_state(DOCUMENT_ACCESS_SOURCE_SCOPES) + repair_required_count = count_document_access_index_repair_documents() + shadow_validation = { + 'status': 'not_run', + 'missing_count': 0, + 'extra_count': 0, + 'source_query_ru': None, + 'projection_query_ru': None, + 'validation_index_ru': None, + 'candidate_read_ru': None, + 'estimated_ru_savings': None, + 'estimated_wave5_ru_savings': None, + 'shadow_overhead_ru': None, + 'source_query_ms': None, + 'projection_query_ms': None, + 'validation_index_ms': None, + 'candidate_read_ms': None, + 'estimated_ms_savings': None, + 'estimated_wave5_ms_savings': None, + 'shadow_overhead_ms': None, + } + if normalized_settings.get('shadow_validation_enabled'): + shadow_validation = _read_shadow_validation_state() or shadow_validation + if isinstance(shadow_validation, dict) and not isinstance(shadow_validation.get('rolling_metrics'), dict): + shadow_validation['rolling_metrics'] = _empty_shadow_rolling_metrics() + return { + 'success': True, + 'state': state, + 'repair_required_count': repair_required_count, + 'maintenance': _build_document_access_index_maintenance_summary( + state, + repair_required_count, + normalized_settings, + ), + 'read_metrics': get_document_access_index_read_metrics(), + 'cache_metrics': get_document_access_index_cache_metrics(), + 'shadow_validation': shadow_validation, + 'settings': { + 'container_enabled': normalized_settings.get('container_enabled'), + 'write_through_enabled': normalized_settings.get('write_through_enabled'), + 'reads_enabled': normalized_settings.get('reads_enabled'), + 'shadow_validation_enabled': normalized_settings.get('shadow_validation_enabled'), + 'startup_backfill_enabled': normalized_settings.get('startup_backfill_enabled'), + 'backfill_batch_size': normalized_settings.get('backfill_batch_size'), + 'repair_batch_size': normalized_settings.get('repair_batch_size'), + 'cache_enabled': normalized_settings.get('cache_enabled'), + 'cache_ttl_seconds': normalized_settings.get('cache_ttl_seconds'), + }, + } + + +def reconcile_document_access_index_repair_documents(settings=None, max_repairs=None, force=False): + """Repair projection rows recorded by fail-open write-through operations.""" + if not _is_container_enabled(settings) and not force: + return { + 'success': True, + 'status': 'skipped_disabled', + 'repairs_processed': 0, + 'repairs_succeeded': 0, + 'repairs_failed': 0, + } + + normalized_settings = get_document_access_index_settings(settings) + max_repairs = _normalize_positive_int( + max_repairs, + normalized_settings.get('repair_batch_size'), + max_value=DOCUMENT_ACCESS_MAX_REPAIR_BATCH_SIZE, + ) + repair_docs = _query_repair_documents(max_item_count=max_repairs) + repairs_succeeded = 0 + repairs_failed = 0 + + for repair_doc in repair_docs: + document_id = str(repair_doc.get('source_document_id') or '').strip() + source_scope = str(repair_doc.get('source_scope') or '').strip().lower() + operation = repair_doc.get('operation') + if not document_id or source_scope not in DOCUMENT_ACCESS_SOURCE_SCOPES: + repairs_failed += 1 + _mark_repair_document_failed(repair_doc, 'Repair document is missing source scope or document id.') + continue + + try: + repair_cache_scope_keys = _normalize_document_access_cache_scope_keys( + repair_doc.get('cache_scope_keys') + ) + if repair_cache_scope_keys: + invalidation_result = invalidate_document_access_index_cache_scope_keys( + repair_cache_scope_keys, + reason='document_access_repair_historical_scope', + settings=settings, + ) + _raise_for_failed_cache_invalidation( + invalidation_result, + 'document_access_repair_historical_scope', + repair_cache_scope_keys, + ) + + if _repair_operation_represents_delete(operation): + delete_document_access_index_for_document( + _repair_document_stub(source_scope, document_id), + operation='document_access_repair_delete', + settings=settings, + force=True, + ) + else: + try: + source_document = _read_source_document_for_repair(source_scope, document_id) + except Exception as source_error: + if not _is_not_found_error(source_error): + raise + delete_document_access_index_for_document( + _repair_document_stub(source_scope, document_id), + operation='document_access_repair_source_missing', + settings=settings, + force=True, + ) + else: + sync_document_access_index_for_document( + source_document, + operation='document_access_repair_sync', + settings=settings, + force=True, + ) + repairs_succeeded += 1 + except Exception as exc: + repairs_failed += 1 + _mark_repair_document_failed(repair_doc, exc) + log_event( + '[DocumentAccessIndex] Projection repair reconciliation failed.', + extra={ + 'document_id': document_id, + 'source_scope': source_scope, + 'operation': operation, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + + try: + _refresh_repair_backlog_state_from_query(reason='repair_reconciliation') + except Exception as exc: + log_event( + '[DocumentAccessIndex] Projection repair backlog state refresh failed after reconciliation.', + extra={'error': str(exc)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return { + 'success': repairs_failed == 0, + 'status': 'reconciled' if repairs_failed == 0 else 'reconciled_with_errors', + 'repairs_processed': len(repair_docs), + 'repairs_succeeded': repairs_succeeded, + 'repairs_failed': repairs_failed, + } + + +def run_document_access_index_backfill_once( + settings=None, + batch_size=None, + source_scopes=None, + reset=False, + force=False, +): + """Run one resumable batch of document access index backfill work.""" + normalized_settings = get_document_access_index_settings(settings) + if not normalized_settings.get('container_enabled'): + return { + 'success': True, + 'status': 'skipped_disabled', + 'processed_count': 0, + 'failed_count': 0, + } + if not force and not normalized_settings.get('startup_backfill_enabled'): + return { + 'success': True, + 'status': 'skipped_disabled', + 'processed_count': 0, + 'failed_count': 0, + } + + source_scopes = _normalize_source_scopes(source_scopes) + batch_size = _normalize_positive_int( + batch_size, + normalized_settings.get('backfill_batch_size'), + max_value=DOCUMENT_ACCESS_MAX_BACKFILL_BATCH_SIZE, + ) + state = None if reset else _read_backfill_state() + if ( + not state + or state.get('type') != DOCUMENT_ACCESS_BACKFILL_STATE_TYPE + or _safe_int(state.get('schema_version')) != DOCUMENT_ACCESS_INDEX_SCHEMA_VERSION + ): + state = _build_initial_backfill_state(source_scopes) + else: + state = copy.deepcopy(state) + state['source_scopes'] = source_scopes + state.setdefault('completed_source_scopes', []) + state.setdefault('continuation_tokens', {}) + state.setdefault('total_documents_processed', 0) + state.setdefault('total_documents_failed', 0) + state.setdefault('total_rows_upserted', 0) + state.setdefault('total_rows_deleted', 0) + + if state.get('status') == 'succeeded' and not reset: + return { + 'success': True, + 'status': 'skipped_completed', + 'processed_count': 0, + 'failed_count': 0, + 'state': state, + } + + run_processed = 0 + run_failed = 0 + run_rows_upserted = 0 + run_rows_deleted = 0 + state['status'] = 'running' + state['last_started_at'] = _utc_now_iso() + state['last_error'] = None + + try: + while run_processed < batch_size: + source_scope = _get_next_backfill_scope( + source_scopes, + state.get('completed_source_scopes', []), + ) + if not source_scope: + break + + continuation_token = state.get('continuation_tokens', {}).get(source_scope) + remaining_count = batch_size - run_processed + page_result = _query_source_document_page( + source_scope, + continuation_token=continuation_token, + max_item_count=remaining_count, + ) + documents = page_result.get('documents', []) + next_token = page_result.get('continuation_token') + + if not documents: + completed_scopes = state.setdefault('completed_source_scopes', []) + if source_scope not in completed_scopes: + completed_scopes.append(source_scope) + state.setdefault('continuation_tokens', {}).pop(source_scope, None) + state['current_source_scope'] = None + continue + + state['current_source_scope'] = source_scope + for document_item in documents: + run_processed += 1 + state['total_documents_processed'] = int(state.get('total_documents_processed') or 0) + 1 + try: + result = sync_document_access_index_for_document( + document_item, + operation='document_access_backfill', + settings=settings, + force=True, + ) + except Exception as exc: + run_failed += 1 + state['total_documents_failed'] = int(state.get('total_documents_failed') or 0) + 1 + try: + _record_projection_repair_required(document_item, 'document_access_backfill', exc) + except Exception as repair_error: + log_event( + '[DocumentAccessIndex] Failed to record backfill repair state.', + extra={ + 'document_id': (document_item or {}).get('id'), + 'source_scope': source_scope, + 'error': str(repair_error), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + log_event( + '[DocumentAccessIndex] Backfill projection failed for one source document.', + extra={ + 'document_id': (document_item or {}).get('id'), + 'source_scope': source_scope, + 'error': str(exc), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + continue + if result.get('success') is False: + run_failed += 1 + state['total_documents_failed'] = int(state.get('total_documents_failed') or 0) + 1 + continue + run_rows_upserted += int(result.get('upserted_count') or 0) + run_rows_deleted += int(result.get('deleted_count') or 0) + state['total_rows_upserted'] = ( + int(state.get('total_rows_upserted') or 0) + + int(result.get('upserted_count') or 0) + ) + state['total_rows_deleted'] = ( + int(state.get('total_rows_deleted') or 0) + + int(result.get('deleted_count') or 0) + ) + + if next_token: + state.setdefault('continuation_tokens', {})[source_scope] = next_token + break + + state.setdefault('continuation_tokens', {}).pop(source_scope, None) + completed_scopes = state.setdefault('completed_source_scopes', []) + if source_scope not in completed_scopes: + completed_scopes.append(source_scope) + state['current_source_scope'] = None + + if _get_next_backfill_scope(source_scopes, state.get('completed_source_scopes', [])): + state['status'] = 'in_progress' + elif int(state.get('total_documents_failed') or 0) > 0: + state['status'] = 'succeeded_with_errors' + else: + state['status'] = 'succeeded' + + state.update({ + 'last_completed_at': _utc_now_iso(), + 'last_processed_count': run_processed, + 'last_failed_count': run_failed, + 'last_rows_upserted': run_rows_upserted, + 'last_rows_deleted': run_rows_deleted, + }) + state = _write_backfill_state(state) + return { + 'success': run_failed == 0, + 'status': state.get('status'), + 'processed_count': run_processed, + 'failed_count': run_failed, + 'rows_upserted': run_rows_upserted, + 'rows_deleted': run_rows_deleted, + 'state': state, + } + except Exception as exc: + state['status'] = 'failed' + state['last_error'] = str(exc) + state['last_completed_at'] = _utc_now_iso() + state = _write_backfill_state(state) + log_event( + '[DocumentAccessIndex] Document access index backfill batch failed.', + extra={'error': str(exc), 'current_source_scope': state.get('current_source_scope')}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return { + 'success': False, + 'status': 'failed', + 'processed_count': run_processed, + 'failed_count': run_failed, + 'error': str(exc), + 'state': state, + } + + +def run_document_access_index_backfill_maintenance( + settings=None, + run_backfill=None, + reset=False, + batch_size=None, + repair_batch_size=None, +): + """Run repair reconciliation and one optional backfill batch for app maintenance.""" + normalized_settings = get_document_access_index_settings(settings) + if run_backfill is None: + run_backfill = normalized_settings.get('startup_backfill_enabled', False) + if not normalized_settings.get('container_enabled'): + return { + 'success': True, + 'status': 'skipped_disabled', + 'repair_reconciliation': { + 'success': True, + 'status': 'skipped_disabled', + }, + 'backfill': { + 'success': True, + 'status': 'skipped_disabled', + }, + 'current_status': get_document_access_index_backfill_status(settings=settings), + } + + repair_result = reconcile_document_access_index_repair_documents( + settings=settings, + max_repairs=repair_batch_size, + force=True, + ) + if run_backfill: + backfill_result = run_document_access_index_backfill_once( + settings=settings, + batch_size=batch_size, + reset=reset, + force=True, + ) + else: + backfill_result = { + 'success': True, + 'status': 'skipped_disabled', + 'processed_count': 0, + 'failed_count': 0, + } + success = repair_result.get('success') and backfill_result.get('success') + current_status = get_document_access_index_backfill_status(settings=settings) + maintenance_pending = is_document_access_index_maintenance_pending(current_status) + status = 'completed' if success else 'completed_with_errors' + if success and maintenance_pending: + status = 'completed_more_work_pending' + return { + 'success': bool(success), + 'status': status, + 'maintenance_pending': maintenance_pending, + 'next_action': (current_status.get('maintenance') or {}).get('next_action'), + 'repair_reconciliation': repair_result, + 'backfill': backfill_result, + 'current_status': current_status, + } diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 3cb0ce3e..df5d1da8 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -9,6 +9,16 @@ from flask import make_response from config import * from functions_appinsights import log_event +from functions_document_access_index import ( + DOCUMENT_ACCESS_SCOPE_GROUP, + DOCUMENT_ACCESS_SCOPE_PERSONAL, + DOCUMENT_ACCESS_SCOPE_PUBLIC, + delete_document_access_index_for_document_fail_open, + is_document_access_shadow_validation_enabled, + query_items_with_cosmos_diagnostics, + sync_document_access_index_for_document_fail_open, + validate_document_access_index_shadow, +) from functions_visio import build_visio_page_markdown, parse_vsdx_pages from functions_content import * from functions_settings import * @@ -678,7 +688,7 @@ def sort_key(document_item): return sorted(documents or [], key=sort_key, reverse=reverse) -def _query_accessible_documents(user_id, group_id=None, public_workspace_id=None): +def _query_accessible_documents(user_id, group_id=None, public_workspace_id=None, collect_diagnostics=False): cosmos_container = _get_documents_container(group_id=group_id, public_workspace_id=public_workspace_id) if public_workspace_id is not None: @@ -715,13 +725,26 @@ def _query_accessible_documents(user_id, group_id=None, public_workspace_id=None {"name": "@user_id_prefix", "value": f"{user_id},"} ] - return list( - cosmos_container.query_items( - query=query, - parameters=parameters, - enable_cross_partition_query=True, - ) + documents, diagnostics = query_items_with_cosmos_diagnostics( + cosmos_container, + diagnostics_label='source_documents', + collect_diagnostics=collect_diagnostics, + query=query, + parameters=parameters, + enable_cross_partition_query=True, + ) + if collect_diagnostics: + return documents, diagnostics + return documents + + +def _upsert_document_and_sync_access_index(cosmos_container, document_item, operation): + persisted_document = cosmos_container.upsert_item(document_item) + sync_document_access_index_for_document_fail_open( + persisted_document if isinstance(persisted_document, dict) else document_item, + operation=operation, ) + return persisted_document if isinstance(persisted_document, dict) else document_item def _build_archived_scope_value(scope_value): @@ -815,7 +838,11 @@ def normalize_document_revision_families(user_id, group_id=None, public_workspac update_occurred = True if update_occurred: - cosmos_container.upsert_item(document_item) + _upsert_document_and_sync_access_index( + cosmos_container, + document_item, + operation='document_revision_normalized', + ) changes_made = True return changes_made @@ -985,7 +1012,11 @@ def create_document(file_name, user_id, document_id, num_file_chunks, status, gr update_existing_document = True if update_existing_document: - cosmos_container.upsert_item(existing_document) + _upsert_document_and_sync_access_index( + cosmos_container, + existing_document, + operation='document_revision_archived', + ) if is_public_workspace: document_metadata = { @@ -1086,7 +1117,11 @@ def create_document(file_name, user_id, document_id, num_file_chunks, status, gr "tags": carried_forward.get("tags", []) } - cosmos_container.upsert_item(document_metadata) + _upsert_document_and_sync_access_index( + cosmos_container, + document_metadata, + operation='document_created', + ) add_file_task_to_file_processing_log( document_id, @@ -2414,7 +2449,11 @@ def update_document(**kwargs): # 5. Upsert the document if changes were made if update_occurred: - cosmos_container.upsert_item(existing_document) + _upsert_document_and_sync_access_index( + cosmos_container, + existing_document, + operation='document_updated', + ) except CosmosResourceNotFoundError as e: # Error already logged where it was first detected @@ -3176,12 +3215,38 @@ def get_ordered_document_chunks(document_id, user_id, group_id=None, public_work def get_documents(user_id, group_id=None, public_workspace_id=None): try: - documents = _query_accessible_documents( + collect_shadow_metrics = is_document_access_shadow_validation_enabled() + if collect_shadow_metrics: + documents, source_query_metrics = _query_accessible_documents( + user_id=user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + collect_diagnostics=True, + ) + else: + documents = _query_accessible_documents( + user_id=user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + source_query_metrics = None + current_documents = sort_documents(select_current_documents(documents)) + source_scope = DOCUMENT_ACCESS_SCOPE_PERSONAL + shadow_group_ids = None + if public_workspace_id is not None: + source_scope = DOCUMENT_ACCESS_SCOPE_PUBLIC + elif group_id is not None: + source_scope = DOCUMENT_ACCESS_SCOPE_GROUP + shadow_group_ids = [group_id] + validate_document_access_index_shadow( + current_documents, + source_scope=source_scope, user_id=user_id, - group_id=group_id, + group_ids=shadow_group_ids, public_workspace_id=public_workspace_id, + source_query_metrics=source_query_metrics, + context='functions_documents.get_documents', ) - current_documents = sort_documents(select_current_documents(documents)) return jsonify({"documents": current_documents}), 200 except Exception as e: return jsonify({'error': f'Error retrieving documents: {str(e)}'}), 500 @@ -3411,6 +3476,10 @@ def delete_document(user_id, document_id, group_id=None, public_workspace_id=Non item=document_id, partition_key=document_id ) + delete_document_access_index_for_document_fail_open( + document_item, + operation='document_deleted', + ) except CosmosResourceNotFoundError: raise Exception("Document not found") @@ -3483,7 +3552,11 @@ def delete_document_revision(user_id, document_id, delete_mode="all_versions", g public_workspace_id=public_workspace_id, ) set_document_chunk_visibility(promoted_document, active=True) - cosmos_container.upsert_item(promoted_document) + _upsert_document_and_sync_access_index( + cosmos_container, + promoted_document, + operation='document_revision_promoted', + ) promoted_document_id = promoted_document.get('id') return { @@ -3722,11 +3795,15 @@ def sync_chat_upload_workspace_document_sharing_for_collaboration(conversation_d continue document_item['last_updated'] = current_time - cosmos_user_documents_container.upsert_item(document_item) + persisted_document = _upsert_document_and_sync_access_index( + cosmos_user_documents_container, + document_item, + operation='chat_upload_collaboration_share_synced', + ) try: set_document_chunk_visibility( - document_item, - active=str(document_item.get('search_visibility_state') or 'active').strip().lower() != 'archived', + persisted_document, + active=str(persisted_document.get('search_visibility_state') or 'active').strip().lower() != 'archived', ) except Exception as chunk_sync_error: log_event( @@ -4898,7 +4975,11 @@ def upload_to_blob(temp_file_path, user_id, document_id, blob_filename, update_c public_workspace_id=public_workspace_id, ) if archived_blob_path: - cosmos_container.upsert_item(previous_document) + _upsert_document_and_sync_access_index( + cosmos_container, + previous_document, + operation='document_blob_archived', + ) blob_service_client = _get_blob_service_client() @@ -4928,7 +5009,11 @@ def upload_to_blob(temp_file_path, user_id, document_id, blob_filename, update_c current_document["enhanced_citations"] = bool(mark_enhanced_citations) if current_document.get("archived_blob_path") is None: current_document["archived_blob_path"] = None - cosmos_container.upsert_item(current_document) + _upsert_document_and_sync_access_index( + cosmos_container, + current_document, + operation='document_blob_uploaded', + ) print(f"Successfully uploaded {blob_filename} to blob storage at {blob_path}") return blob_path @@ -8139,6 +8224,13 @@ def queue_personal_workspace_upload_from_temp_file( item=workspace_document_id, partition_key=workspace_document_id, ) + delete_document_access_index_for_document_fail_open( + { + 'id': workspace_document_id, + 'user_id': user_id, + }, + operation='queued_personal_document_cleanup', + ) except Exception as cleanup_error: debug_print(f"Failed to clean up queued workspace document metadata: {cleanup_error}") if workspace_temp_file_path and os.path.exists(workspace_temp_file_path) and not temp_file_queued: @@ -8289,6 +8381,13 @@ def queue_group_workspace_upload_from_temp_file( item=workspace_document_id, partition_key=workspace_document_id, ) + delete_document_access_index_for_document_fail_open( + { + 'id': workspace_document_id, + 'group_id': group_id, + }, + operation='queued_group_document_cleanup', + ) except Exception as cleanup_error: debug_print(f"Failed to clean up queued group workspace document metadata: {cleanup_error}") if workspace_temp_file_path and os.path.exists(workspace_temp_file_path) and not temp_file_queued: @@ -8946,7 +9045,11 @@ def share_document_with_user(document_id, owner_user_id, target_user_id): document_item['last_updated'] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') # Update the document - cosmos_user_documents_container.upsert_item(document_item) + _upsert_document_and_sync_access_index( + cosmos_user_documents_container, + document_item, + operation='document_shared_with_user', + ) # Update all chunks with the new shared_user_ids try: @@ -9010,7 +9113,11 @@ def unshare_document_from_user(document_id, owner_user_id, target_user_id): document_item['shared_user_ids'] = new_shared_user_ids document_item['last_updated'] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') # Update the document - cosmos_user_documents_container.upsert_item(document_item) + _upsert_document_and_sync_access_index( + cosmos_user_documents_container, + document_item, + operation='document_unshared_from_user', + ) # Update all chunks with the new shared_user_ids try: @@ -9173,7 +9280,11 @@ def share_document_with_group(document_id, owner_group_id, target_group_id): document_item['last_updated'] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') # Update the document - cosmos_group_documents_container.upsert_item(document_item) + _upsert_document_and_sync_access_index( + cosmos_group_documents_container, + document_item, + operation='document_shared_with_group', + ) return True return True # Already shared @@ -9212,7 +9323,11 @@ def unshare_document_from_group(document_id, owner_group_id, target_group_id): document_item['last_updated'] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') # Update the document - cosmos_group_documents_container.upsert_item(document_item) + _upsert_document_and_sync_access_index( + cosmos_group_documents_container, + document_item, + operation='document_unshared_from_group', + ) return True @@ -9463,13 +9578,81 @@ def validate_tag_color(color, tag_name): return True, None, normalized_color +def get_workspace_tag_definitions(user_id, group_id=None, public_workspace_id=None): + """Return tag definition metadata for a personal, group, or public workspace.""" + # Local imports avoid circular initialization with workspace/settings helpers. + if public_workspace_id is not None: + from functions_public_workspaces import find_public_workspace_by_id + ws_doc = find_public_workspace_by_id(public_workspace_id) + workspace_tag_defs = (ws_doc or {}).get('tag_definitions', {}) + elif group_id is not None: + from functions_group import find_group_by_id + group_doc = find_group_by_id(group_id) + workspace_tag_defs = (group_doc or {}).get('tag_definitions', {}) + else: + from functions_settings import get_user_settings + user_settings = get_user_settings(user_id) + settings_dict = user_settings.get('settings', {}) + tag_definitions = settings_dict.get('tag_definitions', {}) + workspace_tag_defs = tag_definitions.get('personal', {}) + + return workspace_tag_defs if isinstance(workspace_tag_defs, dict) else {} + + +def build_workspace_tags_from_counts(tag_counts, user_id, group_id=None, public_workspace_id=None): + """ + Merge normalized tag counts with workspace tag definitions and safe colors. + Returns: [{'name': 'tag1', 'count': 5, 'color': '#3b82f6'}, ...] + """ + normalized_counts = {} + for tag_name, count in (tag_counts or {}).items(): + normalized_tag = normalize_tag(tag_name) + if not normalized_tag: + continue + try: + normalized_count = int(count or 0) + except (TypeError, ValueError): + normalized_count = 0 + normalized_counts[normalized_tag] = normalized_counts.get(normalized_tag, 0) + normalized_count + + workspace_tag_defs = get_workspace_tag_definitions( + user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + + results = [] + for tag_name, count in normalized_counts.items(): + tag_def = workspace_tag_defs.get(tag_name, {}) + if not isinstance(tag_def, dict): + tag_def = {} + results.append({ + 'name': tag_name, + 'count': count, + 'color': get_safe_tag_color(tag_def.get('color'), tag_name) + }) + + for tag_name, tag_def in workspace_tag_defs.items(): + normalized_tag = normalize_tag(tag_name) + if not normalized_tag or normalized_tag in normalized_counts: + continue + if not isinstance(tag_def, dict): + tag_def = {} + results.append({ + 'name': normalized_tag, + 'count': 0, + 'color': get_safe_tag_color(tag_def.get('color'), tag_name) + }) + + results.sort(key=lambda x: (-x['count'], x['name'])) + return results + + def get_workspace_tags(user_id, group_id=None, public_workspace_id=None): """ Get all unique tags used in a workspace with document counts. Returns: [{'name': 'tag1', 'count': 5, 'color': '#3b82f6'}, ...] """ - from functions_settings import get_user_settings - is_group = group_id is not None is_public_workspace = public_workspace_id is not None @@ -9534,47 +9717,12 @@ def get_workspace_tags(user_id, group_id=None, public_workspace_id=None): if normalized_tag: tag_counts[normalized_tag] = tag_counts.get(normalized_tag, 0) + 1 - # Get tag definitions (colors) from the appropriate source - if is_public_workspace: - # Read from public workspace record (shared across all users) - from functions_public_workspaces import find_public_workspace_by_id - ws_doc = find_public_workspace_by_id(public_workspace_id) - workspace_tag_defs = (ws_doc or {}).get('tag_definitions', {}) - elif is_group: - # Read from group record (shared across all group members) - from functions_group import find_group_by_id - group_doc = find_group_by_id(group_id) - workspace_tag_defs = (group_doc or {}).get('tag_definitions', {}) - else: - # Personal: read from user settings - user_settings = get_user_settings(user_id) - settings_dict = user_settings.get('settings', {}) - tag_definitions = settings_dict.get('tag_definitions', {}) - workspace_tag_defs = tag_definitions.get('personal', {}) - - # Build result with colors from used tags - results = [] - for tag_name, count in tag_counts.items(): - tag_def = workspace_tag_defs.get(tag_name, {}) - results.append({ - 'name': tag_name, - 'count': count, - 'color': get_safe_tag_color(tag_def.get('color'), tag_name) - }) - - # Add defined tags that haven't been used yet (count = 0) - for tag_name, tag_def in workspace_tag_defs.items(): - if tag_name not in tag_counts: - results.append({ - 'name': tag_name, - 'count': 0, - 'color': get_safe_tag_color(tag_def.get('color'), tag_name) - }) - - # Sort by count descending, then name ascending - results.sort(key=lambda x: (-x['count'], x['name'])) - - return results + return build_workspace_tags_from_counts( + tag_counts, + user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + ) except Exception as e: print(f"Error getting workspace tags: {e}") diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index 19f0eb60..5203927d 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -18,6 +18,7 @@ hydrate_action_identity_reference, validate_action_identity_reference, ) +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version def get_global_actions(return_type=SecretReturnType.TRIGGER, include_disabled=False): """ @@ -154,6 +155,7 @@ def save_global_action(action_data, user_id=None): existing_plugin=existing_action, ) result = cosmos_global_actions_container.upsert_item(body=action_data) + bump_chat_bootstrap_global_cache_version(reason="global_action_saved") print(f"✅ Global action saved successfully: {result['id']}") return result @@ -183,6 +185,7 @@ def delete_global_action(action_id): item=action_id, partition_key=action_id ) + bump_chat_bootstrap_global_cache_version(reason="global_action_deleted") print(f"✅ Global action deleted successfully: {action_id}") return True @@ -220,10 +223,10 @@ def update_global_action_enabled(action_id, is_enabled, user_id=None): action['modified_at'] = now action['updated_at'] = now result = cosmos_global_actions_container.upsert_item(body=action) + bump_chat_bootstrap_global_cache_version(reason="global_action_enabled_updated") return result except Exception as e: print(f"❌ Error updating enabled state for global action {action_id}: {str(e)}") traceback.print_exc() return None - diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 6bfe6f10..4b23e205 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -17,6 +17,7 @@ from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper from functions_settings import * from functions_agent_payload import sanitize_agent_payload, AgentPayloadError +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version def ensure_default_global_agent_exists(): @@ -256,6 +257,7 @@ def save_global_agent(agent_data, user_id=None): "Global agent saved successfully.", extra={"agent_id": result['id'], "user_id": user_id}, ) + bump_chat_bootstrap_global_cache_version(reason="global_agent_saved") print(f"Global agent saved successfully: {result['id']}") return result except Exception as e: @@ -293,6 +295,7 @@ def delete_global_agent(agent_id): "Global agent deleted successfully.", extra={"agent_id": agent_id, "user_id": user_id}, ) + bump_chat_bootstrap_global_cache_version(reason="global_agent_deleted") print(f"Global agent deleted successfully: {agent_id}") return True except Exception as e: @@ -335,6 +338,7 @@ def update_global_agent_enabled(agent_id, is_enabled, user_id=None): agent['modified_at'] = now agent['updated_at'] = now result = cosmos_global_agents_container.upsert_item(body=agent) + bump_chat_bootstrap_global_cache_version(reason="global_agent_enabled_updated") return result except Exception as e: log_event( diff --git a/application/single_app/functions_group.py b/application/single_app/functions_group.py index 55e8e85b..94ce8a07 100644 --- a/application/single_app/functions_group.py +++ b/application/single_app/functions_group.py @@ -5,6 +5,7 @@ import functions_settings from typing import Iterable +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version from functions_workspace_branding import DEFAULT_WORKSPACE_HERO_COLOR @@ -45,6 +46,7 @@ def create_group(name, description): "modifiedDate": now_str } cosmos_groups_container.create_item(group_doc) + bump_chat_bootstrap_global_cache_version(reason="group_created") return group_doc def search_groups(search_query, user_id): @@ -222,6 +224,7 @@ def delete_group(group_id): Deletes a group from Cosmos DB. Typically only owner can do this. """ cosmos_groups_container.delete_item(item=group_id, partition_key=group_id) + bump_chat_bootstrap_global_cache_version(reason="group_deleted") def is_user_in_group(group_doc, user_id): """ @@ -338,4 +341,5 @@ def update_group_model_endpoints(group_id: str, endpoints): group_doc["model_endpoints"] = endpoints group_doc["modifiedDate"] = datetime.utcnow().isoformat() cosmos_groups_container.upsert_item(group_doc) + bump_chat_bootstrap_global_cache_version(reason="group_model_endpoints_updated") return group_doc \ No newline at end of file diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py index 3455486f..19d58d7b 100644 --- a/application/single_app/functions_group_actions.py +++ b/application/single_app/functions_group_actions.py @@ -23,6 +23,7 @@ validate_action_identity_reference, ) from functions_governance import ensure_action_type_access, filter_actions_by_action_type_access +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -163,6 +164,7 @@ def save_group_action(group_id: str, action_data: Dict[str, Any], user_id: Optio try: stored = cosmos_group_actions_container.upsert_item(body=payload) + bump_chat_bootstrap_global_cache_version(reason="group_action_saved") return _clean_action(stored, group_id, SecretReturnType.TRIGGER) except Exception as exc: debug_print( @@ -187,6 +189,7 @@ def delete_group_action(group_id: str, action_id: str) -> bool: item=action_id, partition_key=group_id, ) + bump_chat_bootstrap_global_cache_version(reason="group_action_deleted") return True except Exception as exc: debug_print( diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index 179e993b..14321ca8 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -18,6 +18,7 @@ ) from functions_agent_payload import sanitize_agent_payload from functions_governance import ensure_governance_access +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -141,6 +142,7 @@ def save_group_agent(group_id: str, agent_data: Dict[str, Any], user_id: Optiona try: stored = cosmos_group_agents_container.upsert_item(body=payload) + bump_chat_bootstrap_global_cache_version(reason="group_agent_saved") return _clean_agent(stored) except Exception as exc: debug_print( @@ -165,6 +167,7 @@ def delete_group_agent(group_id: str, agent_id: str) -> bool: item=agent_id, partition_key=group_id, ) + bump_chat_bootstrap_global_cache_version(reason="group_agent_deleted") return True except Exception as exc: debug_print( diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index 3bd2d6d1..d51aaf4a 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -22,6 +22,7 @@ from config import cosmos_personal_actions_container import logging from functions_governance import ensure_action_type_access, filter_actions_by_action_type_access +from functions_chat_bootstrap_cache import bump_chat_bootstrap_user_cache_version def get_governed_personal_actions(user_id, return_type=SecretReturnType.TRIGGER): @@ -216,6 +217,7 @@ def save_personal_action(user_id, action_data, enforce_governance=True): result = cosmos_personal_actions_container.upsert_item(body=action_data) # Remove Cosmos metadata from response cleaned_result = {k: v for k, v in result.items() if not k.startswith('_')} + bump_chat_bootstrap_user_cache_version(user_id, reason="personal_action_saved") return cleaned_result except Exception as e: @@ -247,6 +249,7 @@ def delete_personal_action(user_id, action_id): item=action['id'], partition_key=user_id ) + bump_chat_bootstrap_user_cache_version(user_id, reason="personal_action_deleted") return True except exceptions.CosmosResourceNotFoundError: diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index f70e0be5..8b177da0 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -21,6 +21,7 @@ from functions_agent_payload import sanitize_agent_payload from functions_debug import debug_print from functions_governance import ensure_governance_access +from functions_chat_bootstrap_cache import bump_chat_bootstrap_user_cache_version def get_personal_agents(user_id): """ @@ -216,6 +217,7 @@ def save_personal_agent(user_id, agent_data, actor_user_id=None): cleaned_result.setdefault('agent_type', 'local') cleaned_result.setdefault('tags', []) cleaned_result.setdefault('icon', {}) + bump_chat_bootstrap_user_cache_version(user_id, reason="personal_agent_saved") return cleaned_result except Exception as e: @@ -249,6 +251,7 @@ def delete_personal_agent(user_id, agent_id): item=agent['id'], partition_key=user_id ) + bump_chat_bootstrap_user_cache_version(user_id, reason="personal_agent_deleted") return True except exceptions.CosmosResourceNotFoundError: debug_print(f"Agent {agent_id} not found for user {user_id}") @@ -333,4 +336,3 @@ def migrate_agents_from_user_settings(user_id): except Exception as e: debug_print(f"Error during agent migration for user {user_id}: {e}") return 0 - diff --git a/application/single_app/functions_prompts.py b/application/single_app/functions_prompts.py index e2e6377b..b45f8cd4 100644 --- a/application/single_app/functions_prompts.py +++ b/application/single_app/functions_prompts.py @@ -1,6 +1,17 @@ # functions_prompts.py from config import * +from functions_chat_bootstrap_cache import ( + bump_chat_bootstrap_global_cache_version, + bump_chat_bootstrap_user_cache_version, +) + + +def _invalidate_prompt_chat_bootstrap_cache(user_id, group_id=None, public_workspace_id=None, reason="prompt_changed"): + if group_id is not None or public_workspace_id is not None: + bump_chat_bootstrap_global_cache_version(reason=reason) + else: + bump_chat_bootstrap_user_cache_version(user_id, reason=reason) def get_pagination_params(args): try: @@ -284,6 +295,12 @@ def create_prompt_doc(name, content, prompt_type, user_id, group_id=None, public doc["user_id"] = user_id created = cosmos_container.create_item(body=doc) + _invalidate_prompt_chat_bootstrap_cache( + user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + reason="prompt_created", + ) return { "id": created["id"], "name": created["name"], @@ -325,6 +342,12 @@ def update_prompt_doc(user_id, prompt_id, prompt_type, updates, group_id=None, p item["updated_at"] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') updated = cosmos_container.replace_item(item=prompt_id, body=item) + _invalidate_prompt_chat_bootstrap_cache( + user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + reason="prompt_updated", + ) return { "id": updated["id"], @@ -349,4 +372,10 @@ def delete_prompt_doc(user_id, prompt_id, group_id=None, public_workspace_id=Non return False cosmos_container.delete_item(item=prompt_id, partition_key=prompt_id) + _invalidate_prompt_chat_bootstrap_cache( + user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + reason="prompt_deleted", + ) return True \ No newline at end of file diff --git a/application/single_app/functions_public_workspaces.py b/application/single_app/functions_public_workspaces.py index 7fcaba73..eda51075 100644 --- a/application/single_app/functions_public_workspaces.py +++ b/application/single_app/functions_public_workspaces.py @@ -6,6 +6,7 @@ from functions_group import * from typing import Iterable +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version from functions_workspace_branding import ( DEFAULT_WORKSPACE_HERO_COLOR, get_workspace_logo_metadata, @@ -43,6 +44,7 @@ def create_public_workspace(name: str, description: str) -> dict: "modifiedDate": now_iso } cosmos_public_workspaces_container.create_item(ws_doc) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_created") return ws_doc @@ -144,6 +146,7 @@ def delete_public_workspace(ws_id: str) -> None: item=ws_id, partition_key=ws_id ) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_deleted") def get_user_role_in_public_workspace(ws_doc: dict, user_id: str) -> str | None: @@ -246,6 +249,7 @@ def add_document_manager(ws_id: str, user_id: str, email: str, display_name: str }) ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_document_manager_added") def remove_document_manager(ws_id: str, user_id: str) -> None: @@ -262,6 +266,7 @@ def remove_document_manager(ws_id: str, user_id: str) -> None: ] ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_document_manager_removed") def approve_document_manager_request(ws_id: str, request_user_id: str) -> None: @@ -276,13 +281,23 @@ def approve_document_manager_request(ws_id: str, request_user_id: str) -> None: new_pend = [] for p in pend: if p["userId"] == request_user_id: - add_document_manager(ws_id, p["userId"], p["email"], p["displayName"]) + existing_manager_ids = { + dm.get("userId") if isinstance(dm, dict) else dm + for dm in ws.get("documentManagers", []) + } + if p["userId"] not in existing_manager_ids: + ws.setdefault("documentManagers", []).append({ + "userId": p["userId"], + "email": p["email"], + "displayName": p["displayName"] + }) else: new_pend.append(p) ws["pendingDocumentManagers"] = new_pend ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_document_manager_request_approved") def reject_document_manager_request(ws_id: str, request_user_id: str) -> None: @@ -299,6 +314,7 @@ def reject_document_manager_request(ws_id: str, request_user_id: str) -> None: ] ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_document_manager_request_rejected") def count_public_workspace_documents(ws_id: str) -> int: diff --git a/application/single_app/functions_redis_monitoring.py b/application/single_app/functions_redis_monitoring.py new file mode 100644 index 00000000..c691e9ff --- /dev/null +++ b/application/single_app/functions_redis_monitoring.py @@ -0,0 +1,904 @@ +# functions_redis_monitoring.py + +from datetime import datetime, timezone +import json +import re +import time + +import app_settings_cache + + +REDIS_MONITORING_STATUS_DISABLED = "disabled" +REDIS_MONITORING_STATUS_NOT_CONFIGURED = "not_configured" +REDIS_MONITORING_STATUS_UNAVAILABLE = "unavailable" +REDIS_MONITORING_STATUS_HEALTHY = "healthy" +REDIS_MONITORING_STATUS_DEGRADED = "degraded" +REDIS_MONITORING_STATUS_ERROR = "error" +REDIS_EXPLORER_DEFAULT_PAGE_SIZE = 25 +REDIS_EXPLORER_MAX_PAGE_SIZE = 100 +REDIS_EXPLORER_MAX_SCAN_ITERATIONS = 5 +REDIS_EXPLORER_MAX_FILTER_LENGTH = 128 +REDIS_EXPLORER_MAX_KEY_LENGTH = 1024 +REDIS_EXPLORER_VALUE_ITEM_LIMIT = 20 +REDIS_EXPLORER_PREVIEW_MAX_CHARS = 4096 +REDIS_EXPLORER_RESTRICTED_KEY_TOKENS = ( + "authorization", + "cookie", + "credential", + "csrf", + "password", + "secret", + "session", + "token", +) +REDIS_EXPLORER_SENSITIVE_FIELD_TOKENS = REDIS_EXPLORER_RESTRICTED_KEY_TOKENS + ( + "api_key", + "connection", + "key", +) +REDIS_EXPLORER_REDACTED_VALUE = "[redacted]" +REDIS_EXPLORER_RESTRICTED_PREVIEW = ( + "Preview restricted because the Redis key name indicates session, token, cookie, or credential data." +) +REDIS_DAI_CACHE_KEY_PREFIX = "DAI_LIST_CACHE" +REDIS_DAI_CACHE_VERSION_KEY_PREFIX = "DAI_LIST_CACHE_VERSION" +REDIS_DAI_CACHE_PAYLOAD_TTL_DEFAULT_SECONDS = 900 +REDIS_DAI_CACHE_PAYLOAD_TTL_MIN_SECONDS = 60 +REDIS_DAI_CACHE_PAYLOAD_TTL_MAX_SECONDS = 900 +REDIS_DAI_CACHE_VERSION_TTL_MIN_SECONDS = 3600 +REDIS_DAI_CACHE_VERSION_TTL_MAX_SECONDS = 86400 +REDIS_DAI_CACHE_VERSION_TTL_MULTIPLIER = 4 +REDIS_DAI_CACHE_KEYSPACE_SCAN_ITERATIONS = 5 +REDIS_EXPLORER_SENSITIVE_TEXT_PATTERN = re.compile( + r"(?i)(api[_-]?key|authorization|connection[_-]?string|password|secret|token)(\s*[:=]\s*)([^\s,;}\]]+)" +) +REDIS_DAI_VERSION_KEY_PATTERN = re.compile(r"^DAI_LIST_CACHE_VERSION:([a-fA-F0-9]{64})$") +REDIS_DAI_PAYLOAD_KEY_PATTERN = re.compile(r"^DAI_LIST_CACHE:([^:]+):([^:]+):([a-fA-F0-9]{64})$") + + +def _utc_now_iso(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _safe_int(value): + if value is None or value == "": + return None + if isinstance(value, bytes): + value = value.decode("utf-8") + try: + return int(value) + except (TypeError, ValueError): + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + +def _safe_str(value): + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + if value is None: + return "" + return str(value) + + +def _safe_float(value): + if value is None or value == "": + return None + if isinstance(value, bytes): + value = value.decode("utf-8") + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _safe_percentage(numerator, denominator): + if numerator is None or denominator is None or denominator <= 0: + return None + return round((numerator / denominator) * 100, 2) + + +def _safe_error_summary(context, exc): + return f"{context} ({exc.__class__.__name__})." + + +def _clamp_int(value, default_value, minimum_value, maximum_value): + numeric_value = _safe_int(value) + if numeric_value is None: + return default_value + return min(max(numeric_value, minimum_value), maximum_value) + + +def _calculate_dai_cache_version_ttl_seconds(settings): + payload_ttl_seconds = _clamp_int( + (settings or {}).get("document_access_index_cache_ttl_seconds"), + REDIS_DAI_CACHE_PAYLOAD_TTL_DEFAULT_SECONDS, + REDIS_DAI_CACHE_PAYLOAD_TTL_MIN_SECONDS, + REDIS_DAI_CACHE_PAYLOAD_TTL_MAX_SECONDS, + ) + return min( + max( + payload_ttl_seconds * REDIS_DAI_CACHE_VERSION_TTL_MULTIPLIER, + REDIS_DAI_CACHE_VERSION_TTL_MIN_SECONDS, + ), + REDIS_DAI_CACHE_VERSION_TTL_MAX_SECONDS, + ) + + +def _build_empty_dai_cache_keyspace(settings): + payload_ttl_seconds = _clamp_int( + (settings or {}).get("document_access_index_cache_ttl_seconds"), + REDIS_DAI_CACHE_PAYLOAD_TTL_DEFAULT_SECONDS, + REDIS_DAI_CACHE_PAYLOAD_TTL_MIN_SECONDS, + REDIS_DAI_CACHE_PAYLOAD_TTL_MAX_SECONDS, + ) + return { + "payload_ttl_seconds": payload_ttl_seconds, + "version_marker_ttl_seconds": _calculate_dai_cache_version_ttl_seconds(settings), + "payload_key_count": 0, + "version_marker_count": 0, + "version_marker_no_expiry_count": 0, + "version_marker_expiring_count": 0, + "version_marker_low_ttl_count": 0, + "version_marker_memory_usage_bytes": 0, + "scan_complete": True, + "scan_error": None, + } + + +def _scan_count_matching_keys(redis_client, pattern): + cursor = 0 + total = 0 + iterations = 0 + while iterations < REDIS_DAI_CACHE_KEYSPACE_SCAN_ITERATIONS: + cursor, keys = redis_client.scan( + cursor=cursor, + match=pattern, + count=REDIS_EXPLORER_MAX_PAGE_SIZE, + ) + cursor = _normalize_redis_cursor(cursor) + total += len(list(keys or [])) + iterations += 1 + if cursor == 0: + break + return total, cursor == 0 + + +def _build_dai_cache_keyspace(redis_client, settings): + keyspace = _build_empty_dai_cache_keyspace(settings) + if redis_client is None: + keyspace["scan_complete"] = False + keyspace["scan_error"] = "Redis client is not available." + return keyspace + + try: + payload_count, payload_scan_complete = _scan_count_matching_keys( + redis_client, + f"{REDIS_DAI_CACHE_KEY_PREFIX}:*", + ) + keyspace["payload_key_count"] = payload_count + keyspace["scan_complete"] = payload_scan_complete + + cursor = 0 + iterations = 0 + while iterations < REDIS_DAI_CACHE_KEYSPACE_SCAN_ITERATIONS: + cursor, keys = redis_client.scan( + cursor=cursor, + match=f"{REDIS_DAI_CACHE_VERSION_KEY_PREFIX}:*", + count=REDIS_EXPLORER_MAX_PAGE_SIZE, + ) + cursor = _normalize_redis_cursor(cursor) + iterations += 1 + for key in list(keys or []): + keyspace["version_marker_count"] += 1 + ttl_seconds = _safe_int(redis_client.ttl(key)) + if ttl_seconds == -1: + keyspace["version_marker_no_expiry_count"] += 1 + elif ttl_seconds is not None and ttl_seconds >= 0: + keyspace["version_marker_expiring_count"] += 1 + if ttl_seconds < keyspace["payload_ttl_seconds"]: + keyspace["version_marker_low_ttl_count"] += 1 + keyspace["version_marker_memory_usage_bytes"] += _get_redis_memory_usage(redis_client, key) or 0 + if cursor == 0: + break + keyspace["scan_complete"] = keyspace["scan_complete"] and cursor == 0 + except Exception as exc: + keyspace["scan_complete"] = False + keyspace["scan_error"] = _safe_error_summary("DAI cache keyspace scan failed", exc) + return keyspace + + +def _redact_sensitive_text(value): + return REDIS_EXPLORER_SENSITIVE_TEXT_PATTERN.sub( + lambda match: f"{match.group(1)}{match.group(2)}{REDIS_EXPLORER_REDACTED_VALUE}", + _safe_str(value), + ) + + +def _contains_sensitive_token(value, sensitive_tokens=REDIS_EXPLORER_RESTRICTED_KEY_TOKENS): + normalized_value = _safe_str(value).lower() + return any(token in normalized_value for token in sensitive_tokens) + + +def _sanitize_preview_payload(value, parent_key=""): + if _contains_sensitive_token(parent_key, REDIS_EXPLORER_SENSITIVE_FIELD_TOKENS): + return REDIS_EXPLORER_REDACTED_VALUE + if isinstance(value, dict): + return { + _safe_str(key): _sanitize_preview_payload(item_value, parent_key=_safe_str(key)) + for key, item_value in list(value.items())[:REDIS_EXPLORER_VALUE_ITEM_LIMIT] + } + if isinstance(value, (list, tuple, set)): + sanitized_items = [ + _sanitize_preview_payload(item, parent_key=parent_key) + for item in list(value)[:REDIS_EXPLORER_VALUE_ITEM_LIMIT] + ] + if len(value) > REDIS_EXPLORER_VALUE_ITEM_LIMIT: + sanitized_items.append(f"... {len(value) - REDIS_EXPLORER_VALUE_ITEM_LIMIT} more item(s)") + return sanitized_items + if isinstance(value, bytes): + return _redact_sensitive_text(value) + if isinstance(value, str): + return _redact_sensitive_text(value) + return value + + +def _build_preview_response(value, preview_format="text"): + truncated = False + if preview_format == "json": + sanitized_payload = _sanitize_preview_payload(value) + preview = json.dumps(sanitized_payload, indent=2, sort_keys=True, default=str) + else: + preview = _redact_sensitive_text(value) + + if len(preview) > REDIS_EXPLORER_PREVIEW_MAX_CHARS: + preview = preview[:REDIS_EXPLORER_PREVIEW_MAX_CHARS] + truncated = True + + return { + "preview": preview, + "preview_format": preview_format, + "redacted": REDIS_EXPLORER_REDACTED_VALUE in preview, + "truncated": truncated, + } + + +def _build_string_preview(value): + text_value = _safe_str(value) + try: + decoded_json = json.loads(text_value) + except (TypeError, ValueError, json.JSONDecodeError): + return _build_preview_response(text_value, preview_format="text") + return _build_preview_response(decoded_json, preview_format="json") + + +def _build_collection_preview(value): + return _build_preview_response(value, preview_format="json") + + +def _escape_redis_glob(value): + escaped_value = "" + for character in _safe_str(value): + if character in ("*", "?", "[", "]", "\\"): + escaped_value += f"\\{character}" + else: + escaped_value += character + return escaped_value + + +def _normalize_key_filter(raw_filter): + filter_text = _safe_str(raw_filter).strip() + if not filter_text: + return "" + return filter_text[:REDIS_EXPLORER_MAX_FILTER_LENGTH] + + +def _build_scan_pattern(raw_filter): + normalized_filter = _normalize_key_filter(raw_filter) + if not normalized_filter: + return "*" + return f"*{_escape_redis_glob(normalized_filter)}*" + + +def _normalize_redis_cursor(raw_cursor): + cursor_value = _safe_int(raw_cursor) + if cursor_value is None or cursor_value < 0: + return 0 + return cursor_value + + +def _normalize_redis_key(raw_key): + key = _safe_str(raw_key).strip() + if not key: + raise ValueError("Redis key is required.") + if len(key) > REDIS_EXPLORER_MAX_KEY_LENGTH: + raise ValueError("Redis key is too long.") + return key + + +def _get_redis_type(redis_client, key): + return _safe_str(redis_client.type(key)).lower() + + +def _get_redis_memory_usage(redis_client, key): + try: + return _safe_int(redis_client.memory_usage(key)) + except (AttributeError, TypeError): + return None + + +def _build_resolution_payload(kind, label, resolved=False, **kwargs): + payload = { + "kind": kind, + "label": label, + "resolved": bool(resolved), + } + payload.update(kwargs) + return payload + + +def _resolve_dai_version_hashes(scope_hashes, dai_hash_resolver=None): + resolver = dai_hash_resolver + if resolver is None: + try: + # Imported lazily because the DAI module initializes Cosmos-backed containers. + from functions_document_access_index import resolve_document_access_cache_version_hashes + + resolver = resolve_document_access_cache_version_hashes + except Exception as exc: + return { + scope_hash: _build_resolution_payload( + "document_access_index_scope_version", + "DAI scope version marker", + resolved=False, + resolution_status="unavailable", + scope_hash=scope_hash, + note=_safe_error_summary("DAI hash resolution unavailable", exc), + ) + for scope_hash in scope_hashes + } + try: + return resolver(scope_hashes) + except Exception as exc: + return { + scope_hash: _build_resolution_payload( + "document_access_index_scope_version", + "DAI scope version marker", + resolved=False, + resolution_status="unavailable", + scope_hash=scope_hash, + note=_safe_error_summary("DAI hash resolution failed", exc), + ) + for scope_hash in scope_hashes + } + + +def _resolve_redis_keys(keys, dai_hash_resolver=None): + resolutions = {} + dai_version_hashes = [] + dai_version_keys_by_hash = {} + for key in list(keys or []): + normalized_key = _safe_str(key) + dai_version_match = REDIS_DAI_VERSION_KEY_PATTERN.match(normalized_key) + if dai_version_match: + scope_hash = dai_version_match.group(1).lower() + dai_version_hashes.append(scope_hash) + dai_version_keys_by_hash[scope_hash] = normalized_key + continue + + dai_payload_match = REDIS_DAI_PAYLOAD_KEY_PATTERN.match(normalized_key) + if dai_payload_match: + operation, source_scope, cache_hash = dai_payload_match.groups() + resolutions[normalized_key] = _build_resolution_payload( + "document_access_index_payload", + "DAI read-through cache payload", + resolved=False, + operation=operation, + source_scope=source_scope, + cache_hash=cache_hash.lower(), + note="Payload keys include filters, scope versions, and process epoch, so the key hash is not reversible from Redis alone.", + ) + continue + + if normalized_key == app_settings_cache.APP_SETTINGS_CACHE_KEY: + resolutions[normalized_key] = _build_resolution_payload( + "app_settings_cache", + "App settings cache payload", + resolved=True, + note="Global app settings cache payload.", + ) + elif normalized_key == app_settings_cache.APP_SETTINGS_CACHE_VERSION_KEY: + resolutions[normalized_key] = _build_resolution_payload( + "app_settings_cache_version", + "App settings cache version", + resolved=True, + note="Global app settings cache invalidation version.", + ) + + if dai_version_hashes: + dai_resolutions = _resolve_dai_version_hashes(dai_version_hashes, dai_hash_resolver=dai_hash_resolver) + for scope_hash, resolution in (dai_resolutions or {}).items(): + key = dai_version_keys_by_hash.get(scope_hash) + if key: + resolutions[key] = resolution + return resolutions + + +def _build_redis_key_summary(redis_client, key, resolution=None): + normalized_key = _safe_str(key) + return { + "key": normalized_key, + "type": _get_redis_type(redis_client, normalized_key), + "ttl_seconds": _safe_int(redis_client.ttl(normalized_key)), + "memory_usage_bytes": _get_redis_memory_usage(redis_client, normalized_key), + "preview_restricted": _contains_sensitive_token(normalized_key), + "resolution": resolution, + } + + +def _build_explorer_unavailable_status(settings, app_cache_client, session_redis_client, session_type): + monitoring_status = get_redis_monitoring_status( + settings, + app_cache_client=app_cache_client, + session_redis_client=session_redis_client, + session_type=session_type, + ) + return { + "success": False, + "status": monitoring_status.get("health", {}).get("status") or REDIS_MONITORING_STATUS_UNAVAILABLE, + "checked_at": monitoring_status.get("checked_at"), + "runtime": monitoring_status.get("runtime", {}), + "last_error": monitoring_status.get("health", {}).get("last_error"), + } + + +def _resolve_explorer_client(settings, app_cache_client=None, session_redis_client=None, session_type=None): + safe_settings = settings if isinstance(settings, dict) else {} + resolved_app_cache_client = ( + app_cache_client + if app_cache_client is not None + else app_settings_cache.get_app_cache_redis_client() + ) + monitoring_client, monitoring_source = _select_monitoring_client( + resolved_app_cache_client, + session_redis_client, + ) + + if not bool(safe_settings.get("enable_redis_cache")): + return None, _build_explorer_unavailable_status( + safe_settings, + resolved_app_cache_client, + session_redis_client, + session_type, + ) + if not str(safe_settings.get("redis_url") or "").strip(): + return None, _build_explorer_unavailable_status( + safe_settings, + resolved_app_cache_client, + session_redis_client, + session_type, + ) + if monitoring_client is None: + return None, _build_explorer_unavailable_status( + safe_settings, + resolved_app_cache_client, + session_redis_client, + session_type, + ) + + return monitoring_client, { + "success": True, + "status": REDIS_MONITORING_STATUS_HEALTHY, + "checked_at": _utc_now_iso(), + "runtime": { + "monitoring_source": monitoring_source, + "client_available": True, + }, + } + + +def _parse_keyspace_database(value): + if isinstance(value, dict): + return { + "keys": _safe_int(value.get("keys")), + "expires": _safe_int(value.get("expires")), + "avg_ttl": _safe_int(value.get("avg_ttl")), + } + + if isinstance(value, bytes): + value = value.decode("utf-8") + + parsed = {} + for segment in str(value or "").split(","): + if "=" not in segment: + continue + key, raw_value = segment.split("=", 1) + parsed[key.strip()] = _safe_int(raw_value.strip()) + + return { + "keys": parsed.get("keys"), + "expires": parsed.get("expires"), + "avg_ttl": parsed.get("avg_ttl"), + } + + +def _extract_keyspace(info): + databases = {} + total_keys = 0 + expiring_keys = 0 + for key, value in (info or {}).items(): + key_name = str(key) + if not key_name.startswith("db"): + continue + database = _parse_keyspace_database(value) + databases[key_name] = database + total_keys += database.get("keys") or 0 + expiring_keys += database.get("expires") or 0 + + return { + "databases": databases, + "total_keys": total_keys, + "expiring_keys": expiring_keys, + } + + +def _build_empty_metric_sections(): + return { + "server": {}, + "clients": {}, + "memory": {}, + "stats": {}, + "keyspace": { + "databases": {}, + "total_keys": 0, + "expiring_keys": 0, + }, + } + + +def _build_info_metric_sections(info): + used_memory = _safe_int(info.get("used_memory")) + maxmemory = _safe_int(info.get("maxmemory")) + keyspace_hits = _safe_int(info.get("keyspace_hits")) + keyspace_misses = _safe_int(info.get("keyspace_misses")) + keyspace_total = (keyspace_hits or 0) + (keyspace_misses or 0) + + return { + "server": { + "redis_version": info.get("redis_version"), + "uptime_in_seconds": _safe_int(info.get("uptime_in_seconds")), + "uptime_in_days": _safe_int(info.get("uptime_in_days")), + }, + "clients": { + "connected_clients": _safe_int(info.get("connected_clients")), + "blocked_clients": _safe_int(info.get("blocked_clients")), + "tracking_clients": _safe_int(info.get("tracking_clients")), + "maxclients": _safe_int(info.get("maxclients")), + }, + "memory": { + "used_memory": used_memory, + "used_memory_human": info.get("used_memory_human"), + "maxmemory": maxmemory, + "maxmemory_human": info.get("maxmemory_human"), + "maxmemory_policy": info.get("maxmemory_policy"), + "usage_percent": _safe_percentage(used_memory, maxmemory), + "mem_fragmentation_ratio": _safe_float(info.get("mem_fragmentation_ratio")), + }, + "stats": { + "total_connections_received": _safe_int(info.get("total_connections_received")), + "total_commands_processed": _safe_int(info.get("total_commands_processed")), + "instantaneous_ops_per_sec": _safe_int(info.get("instantaneous_ops_per_sec")), + "keyspace_hits": keyspace_hits, + "keyspace_misses": keyspace_misses, + "keyspace_hit_rate_percent": _safe_percentage(keyspace_hits, keyspace_total), + "expired_keys": _safe_int(info.get("expired_keys")), + "evicted_keys": _safe_int(info.get("evicted_keys")), + "rejected_connections": _safe_int(info.get("rejected_connections")), + "total_error_replies": _safe_int(info.get("total_error_replies")), + }, + "keyspace": _extract_keyspace(info), + } + + +def _select_monitoring_client(app_cache_client, session_redis_client): + if app_cache_client is not None: + return app_cache_client, "app_cache" + if session_redis_client is not None: + return session_redis_client, "session" + return None, "none" + + +def get_redis_monitoring_status( + settings, + app_cache_client=None, + session_redis_client=None, + session_type=None, + now_func=None, +): + """Return sanitized Redis health and capacity metrics for admin monitoring.""" + safe_settings = settings if isinstance(settings, dict) else {} + enabled = bool(safe_settings.get("enable_redis_cache")) + configured = bool(str(safe_settings.get("redis_url") or "").strip()) + auth_type = str(safe_settings.get("redis_auth_type") or "key").strip().lower() or "key" + resolved_app_cache_client = ( + app_cache_client + if app_cache_client is not None + else app_settings_cache.get_app_cache_redis_client() + ) + app_cache_using_redis = bool(app_settings_cache.app_cache_is_using_redis and resolved_app_cache_client) + session_using_redis = str(session_type or "").strip().lower() == "redis" and session_redis_client is not None + monitoring_client, monitoring_source = _select_monitoring_client( + resolved_app_cache_client, + session_redis_client, + ) + checked_at = (now_func or _utc_now_iso)() + + status = { + "checked_at": checked_at, + "configuration": { + "enabled": enabled, + "configured": configured, + "auth_type": auth_type, + }, + "runtime": { + "app_cache_using_redis": app_cache_using_redis, + "session_using_redis": session_using_redis, + "monitoring_source": monitoring_source, + "client_available": monitoring_client is not None, + }, + "health": { + "status": REDIS_MONITORING_STATUS_DISABLED, + "ping_success": False, + "ping_latency_ms": None, + "last_error": None, + }, + "dai_cache": _build_empty_dai_cache_keyspace(safe_settings), + **_build_empty_metric_sections(), + } + + if not enabled: + return status + + if not configured: + status["health"]["status"] = REDIS_MONITORING_STATUS_NOT_CONFIGURED + status["health"]["last_error"] = "Redis cache is enabled but no host name is configured." + return status + + if monitoring_client is None: + status["health"]["status"] = REDIS_MONITORING_STATUS_UNAVAILABLE + status["health"]["last_error"] = "Redis is configured, but no active runtime Redis client is available." + return status + + ping_started_at = time.perf_counter() + try: + status["health"]["ping_success"] = bool(monitoring_client.ping()) + status["health"]["ping_latency_ms"] = round((time.perf_counter() - ping_started_at) * 1000, 2) + except Exception as exc: + status["health"]["status"] = REDIS_MONITORING_STATUS_ERROR + status["health"]["last_error"] = _safe_error_summary("Redis ping failed", exc) + return status + + try: + info = monitoring_client.info() + except Exception as exc: + status["health"]["status"] = REDIS_MONITORING_STATUS_DEGRADED + status["health"]["last_error"] = _safe_error_summary("Redis INFO metrics failed", exc) + return status + + if not isinstance(info, dict): + status["health"]["status"] = REDIS_MONITORING_STATUS_DEGRADED + status["health"]["last_error"] = "Redis INFO metrics returned an unexpected response." + return status + + status.update(_build_info_metric_sections(info)) + status["dai_cache"] = _build_dai_cache_keyspace(monitoring_client, safe_settings) + status["health"]["status"] = ( + REDIS_MONITORING_STATUS_HEALTHY + if status["health"]["ping_success"] + else REDIS_MONITORING_STATUS_DEGRADED + ) + return status + + +def get_redis_explorer_keys( + settings, + cursor=0, + page_size=REDIS_EXPLORER_DEFAULT_PAGE_SIZE, + key_filter="", + app_cache_client=None, + session_redis_client=None, + session_type=None, + dai_hash_resolver=None, +): + """Return one cursor-paginated Redis key page with safe metadata only.""" + redis_client, status = _resolve_explorer_client( + settings, + app_cache_client=app_cache_client, + session_redis_client=session_redis_client, + session_type=session_type, + ) + if redis_client is None: + return { + **status, + "keys": [], + "cursor": str(_normalize_redis_cursor(cursor)), + "next_cursor": "0", + "has_more": False, + "page_size": _clamp_int( + page_size, + REDIS_EXPLORER_DEFAULT_PAGE_SIZE, + 1, + REDIS_EXPLORER_MAX_PAGE_SIZE, + ), + "filter": _normalize_key_filter(key_filter), + } + + normalized_cursor = _normalize_redis_cursor(cursor) + normalized_page_size = _clamp_int( + page_size, + REDIS_EXPLORER_DEFAULT_PAGE_SIZE, + 1, + REDIS_EXPLORER_MAX_PAGE_SIZE, + ) + normalized_filter = _normalize_key_filter(key_filter) + scan_pattern = _build_scan_pattern(normalized_filter) + next_cursor = normalized_cursor + collected_keys = [] + scan_iterations = 0 + + while len(collected_keys) < normalized_page_size and scan_iterations < REDIS_EXPLORER_MAX_SCAN_ITERATIONS: + raw_cursor, scanned_keys = redis_client.scan( + cursor=next_cursor, + match=scan_pattern, + count=normalized_page_size, + ) + next_cursor = _normalize_redis_cursor(raw_cursor) + scan_iterations += 1 + for key in list(scanned_keys or []): + if len(collected_keys) >= normalized_page_size: + break + collected_keys.append(_safe_str(key)) + if next_cursor == 0: + break + + resolutions = _resolve_redis_keys(collected_keys, dai_hash_resolver=dai_hash_resolver) + return { + **status, + "keys": [ + _build_redis_key_summary(redis_client, key, resolution=resolutions.get(key)) + for key in collected_keys + ], + "cursor": str(normalized_cursor), + "next_cursor": str(next_cursor), + "has_more": next_cursor != 0, + "page_size": normalized_page_size, + "filter": normalized_filter, + "scan_iterations": scan_iterations, + } + + +def _read_redis_value_preview(redis_client, key, key_type): + if key_type == "string": + return _build_string_preview(redis_client.get(key)) + if key_type == "hash": + _cursor, values = redis_client.hscan(key, cursor=0, count=REDIS_EXPLORER_VALUE_ITEM_LIMIT) + return _build_collection_preview({ + _safe_str(field): _safe_str(value) + for field, value in dict(values or {}).items() + }) + if key_type == "list": + return _build_collection_preview([ + _safe_str(value) + for value in list(redis_client.lrange(key, 0, REDIS_EXPLORER_VALUE_ITEM_LIMIT - 1) or []) + ]) + if key_type == "set": + _cursor, values = redis_client.sscan(key, cursor=0, count=REDIS_EXPLORER_VALUE_ITEM_LIMIT) + return _build_collection_preview([_safe_str(value) for value in list(values or [])]) + if key_type == "zset": + return _build_collection_preview([ + { + "value": _safe_str(value), + "score": score, + } + for value, score in list(redis_client.zrange( + key, + 0, + REDIS_EXPLORER_VALUE_ITEM_LIMIT - 1, + withscores=True, + ) or []) + ]) + if key_type == "stream": + return _build_collection_preview([ + { + "id": _safe_str(entry_id), + "fields": { + _safe_str(field): _safe_str(value) + for field, value in dict(fields or {}).items() + }, + } + for entry_id, fields in list(redis_client.xrange( + key, + min="-", + max="+", + count=REDIS_EXPLORER_VALUE_ITEM_LIMIT, + ) or []) + ]) + return { + "preview": f"Preview is not available for Redis key type '{key_type}'.", + "preview_format": "text", + "redacted": False, + "truncated": False, + } + + +def get_redis_explorer_value( + settings, + key, + app_cache_client=None, + session_redis_client=None, + session_type=None, + dai_hash_resolver=None, +): + """Return sanitized metadata and preview content for one Redis key.""" + normalized_key = _normalize_redis_key(key) + redis_client, status = _resolve_explorer_client( + settings, + app_cache_client=app_cache_client, + session_redis_client=session_redis_client, + session_type=session_type, + ) + if redis_client is None: + return { + **status, + "key": normalized_key, + "type": "unknown", + "preview": "", + "preview_format": "text", + "redacted": False, + "truncated": False, + "preview_restricted": False, + } + + key_type = _get_redis_type(redis_client, normalized_key) + if key_type in ("", "none"): + return { + **status, + "success": False, + "status": "not_found", + "key": normalized_key, + "type": "none", + "ttl_seconds": -2, + "memory_usage_bytes": None, + "preview": "Redis key was not found.", + "preview_format": "text", + "redacted": False, + "truncated": False, + "preview_restricted": False, + } + + resolutions = _resolve_redis_keys([normalized_key], dai_hash_resolver=dai_hash_resolver) + metadata = { + "key": normalized_key, + "type": key_type, + "ttl_seconds": _safe_int(redis_client.ttl(normalized_key)), + "memory_usage_bytes": _get_redis_memory_usage(redis_client, normalized_key), + "preview_restricted": _contains_sensitive_token(normalized_key), + "resolution": resolutions.get(normalized_key), + } + if metadata["preview_restricted"]: + return { + **status, + **metadata, + "preview": REDIS_EXPLORER_RESTRICTED_PREVIEW, + "preview_format": "text", + "redacted": True, + "truncated": False, + } + + preview = _read_redis_value_preview(redis_client, normalized_key, key_type) + return { + **status, + **metadata, + **preview, + } diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 59a73c33..d48079f5 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -90,6 +90,25 @@ def resolve_admin_settings_secret_value(field_name, submitted_value, existing_se return str(_get_nested_setting_value(existing_settings, field_name) or '').strip() +def normalize_document_access_index_required_settings(settings): + """Force DAI operational settings that are required for the default read path.""" + if not isinstance(settings, dict): + return False + + changed = False + required_flags = { + 'enable_document_access_index_container': True, + 'enable_document_access_index_write_through': True, + 'enable_document_access_index_reads': True, + 'enable_startup_document_access_index_backfill': True, + } + for key, required_value in required_flags.items(): + if settings.get(key) != required_value: + settings[key] = required_value + changed = True + return changed + + def redact_admin_settings_secrets_for_form(settings): redacted_settings = copy.deepcopy(settings or {}) for field_name in ADMIN_SETTINGS_FORM_SECRET_FIELDS: @@ -715,44 +734,6 @@ def _update_cache(stage): _update_cache("after_version_bump") -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 get_settings(use_cosmos=False, include_source=False): import secrets default_settings = { @@ -931,6 +912,28 @@ def get_settings(use_cosmos=False, include_source=False): 'redis_key': '', 'redis_auth_type': '', + # App Maintenance Settings + 'enable_app_maintenance': True, + 'enable_startup_app_maintenance': True, + 'app_maintenance_check_interval_seconds': 3600, + 'app_maintenance_job_lease_seconds': 300, + 'app_maintenance_apply_cosmos_indexing_policies': False, + 'enable_document_access_index_container': True, + 'enable_document_access_index_write_through': True, + 'enable_document_access_index_reads': True, + 'enable_dai_debug': False, + 'enable_document_access_index_shadow_validation': False, + 'enable_startup_document_access_index_backfill': True, + 'document_access_index_backfill_batch_size': 200, + 'document_access_index_repair_batch_size': 100, + 'document_access_index_active_maintenance_interval_seconds': 30, + 'enable_document_access_index_cache': True, + 'document_access_index_cache_ttl_seconds': 900, + 'custom_pages_nav_cache_ttl_seconds': 60, + 'chat_bootstrap_cache_ttl_seconds': 300, + 'enable_conversation_cache': True, + 'conversation_cache_ttl_seconds': 120, + # Cosmos DB Throughput Scale Settings **get_default_cosmos_throughput_settings(), @@ -1343,11 +1346,18 @@ def _format_result(settings_payload, source): migration_updated = apply_custom_endpoint_setting_migration(merged) assignment_settings_updated = normalize_group_workflow_assignment_settings(merged) promoted_popular_settings_updated = normalize_agents_page_promoted_popular_settings(merged) + document_access_index_settings_updated = normalize_document_access_index_required_settings(merged) merged['enable_tabular_processing_plugin'] = is_tabular_processing_enabled(merged) # If merging added anything new, upsert back to Cosmos so future reads remain up to date - if merge_changed or migration_updated or assignment_settings_updated or promoted_popular_settings_updated: + if ( + merge_changed + or migration_updated + or assignment_settings_updated + or promoted_popular_settings_updated + or document_access_index_settings_updated + ): cosmos_settings_container.upsert_item(merged) _refresh_app_settings_cache_after_write(merged, context="merge_upsert") @@ -1396,6 +1406,7 @@ def update_settings(new_settings): settings_item.update(new_settings) normalize_group_workflow_assignment_settings(settings_item) normalize_agents_page_promoted_popular_settings(settings_item) + normalize_document_access_index_required_settings(settings_item) settings_item['enable_multi_model_endpoints'] = coerce_multi_model_endpoint_enablement( existing_multi_endpoint_enabled, settings_item.get('enable_multi_model_endpoints', False), diff --git a/application/single_app/functions_shared_cache.py b/application/single_app/functions_shared_cache.py new file mode 100644 index 00000000..11c2c5ba --- /dev/null +++ b/application/single_app/functions_shared_cache.py @@ -0,0 +1,464 @@ +# functions_shared_cache.py +"""Shared versioned cache helpers with Redis-first and Cosmos fallback behavior.""" + +import copy +import hashlib +import json +import logging +import threading +import time +from datetime import datetime, timedelta, timezone + +from azure.core import MatchConditions +from config import cosmos_settings_container +from functions_appinsights import log_event + + +SHARED_CACHE_ENTRY_DOC_TYPE = 'shared_cache_entry' +SHARED_CACHE_ENTRY_PREFIX = 'shared_cache_entry:' +SHARED_CACHE_VERSION_DOC_TYPE = 'cache_version' +SHARED_CACHE_VERSION_READ_TTL_SECONDS = 15 +SHARED_CACHE_VERSION_BUMP_MAX_RETRIES = 5 + +_version_cache = {} +_version_cache_lock = None +_shared_cache_metrics = { + 'counts': {}, + 'last_event': None, +} +_shared_cache_metrics_lock = threading.Lock() + + +def _get_version_cache_lock(): + global _version_cache_lock + if _version_cache_lock is None: + _version_cache_lock = threading.Lock() + return _version_cache_lock + + +def _utc_now(): + return datetime.now(timezone.utc) + + +def _utc_now_iso(): + return _utc_now().isoformat() + + +def _normalize_cache_version(value): + if isinstance(value, bytes): + value = value.decode('utf-8') + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _is_not_found_error(exc): + return getattr(exc, 'status_code', None) == 404 + + +def _log_cache_warning(operation, error, extra=None): + context = { + 'operation': operation, + 'error': str(error), + } + if extra: + context.update(extra) + log_event( + f"[SharedCache] {operation} failed.", + extra=context, + level=logging.WARNING, + exceptionTraceback=True, + ) + + +def _hash_cache_key(cache_key): + normalized_key = str(cache_key or '').strip() + if not normalized_key: + return '' + return hashlib.sha256(normalized_key.encode('utf-8')).hexdigest()[:16] + + +def _record_cache_event(operation, namespace=None, key=None, backend='cosmos', result='success', extra=None): + safe_event = { + 'operation': str(operation or '').strip() or 'unknown', + 'namespace': str(namespace or '').strip() or 'shared', + 'backend': str(backend or '').strip() or 'unknown', + 'result': str(result or '').strip() or 'unknown', + } + if key is not None: + safe_event['cache_key_hash'] = _hash_cache_key(key) + if extra: + safe_event.update(extra) + + metric_key = ':'.join([ + safe_event['namespace'], + safe_event['operation'], + safe_event['backend'], + safe_event['result'], + ]) + with _shared_cache_metrics_lock: + counts = _shared_cache_metrics.setdefault('counts', {}) + counts[metric_key] = int(counts.get(metric_key) or 0) + 1 + _shared_cache_metrics['last_event'] = copy.deepcopy(safe_event) + + log_event( + f"[SharedCache] {safe_event['operation']} {safe_event['result']}.", + extra=safe_event, + level=logging.INFO, + debug_only=True, + ) + + +def get_shared_cache_metrics(): + """Return lightweight in-process shared cache counters for diagnostics/tests.""" + with _shared_cache_metrics_lock: + return copy.deepcopy(_shared_cache_metrics) + + +def reset_shared_cache_metrics(): + """Reset lightweight shared cache counters for deterministic functional tests.""" + with _shared_cache_metrics_lock: + _shared_cache_metrics['counts'] = {} + _shared_cache_metrics['last_event'] = None + + +def _build_entry_doc_id(namespace, key): + normalized_namespace = str(namespace or '').strip() + normalized_key = str(key or '').strip() + if not normalized_namespace or not normalized_key: + raise ValueError('Shared cache namespace and key are required.') + return f"{SHARED_CACHE_ENTRY_PREFIX}{normalized_namespace}:{normalized_key}" + + +def _build_redis_key(namespace, key): + return _build_entry_doc_id(namespace, key) + + +def _serialize_for_cache(value): + return json.dumps(value, default=str) + + +def _deserialize_from_cache(value): + if value is None: + return None + if isinstance(value, bytes): + value = value.decode('utf-8') + return json.loads(value) + + +def _get_expires_at(ttl_seconds=None): + if ttl_seconds is None: + return None + return _utc_now() + timedelta(seconds=max(int(ttl_seconds), 0)) + + +def _is_expired(entry): + expires_at = entry.get('expires_at') if isinstance(entry, dict) else None + if not expires_at: + return False + try: + return datetime.fromisoformat(expires_at) <= _utc_now() + except (TypeError, ValueError): + return True + + +def set_shared_cache_entry(namespace, key, value, ttl_seconds=None, redis_client=None, allow_cosmos_fallback=True): + """Set a shared cache entry, optionally falling back to Cosmos when Redis is unavailable.""" + redis_key = _build_redis_key(namespace, key) + if redis_client is not None: + try: + serialized_value = _serialize_for_cache(value) + if ttl_seconds is None: + redis_client.set(redis_key, serialized_value) + else: + redis_client.setex(redis_key, int(ttl_seconds), serialized_value) + _record_cache_event('set', namespace, redis_key, backend='redis', result='success') + return True + except Exception as ex: + _record_cache_event('set', namespace, redis_key, backend='redis', result='error') + _log_cache_warning('redis_set_shared_cache_entry', ex, {'cache_key': redis_key}) + if not allow_cosmos_fallback: + _record_cache_event('set', namespace, redis_key, backend='cosmos', result='skipped') + return False + elif not allow_cosmos_fallback: + _record_cache_event('set', namespace, redis_key, backend='cosmos', result='skipped') + return False + + expires_at = _get_expires_at(ttl_seconds) + body = { + 'id': redis_key, + 'type': SHARED_CACHE_ENTRY_DOC_TYPE, + 'namespace': str(namespace), + 'key': str(key), + 'value': copy.deepcopy(value), + 'expires_at': expires_at.isoformat() if expires_at else None, + 'updated_at': _utc_now_iso(), + } + try: + cosmos_settings_container.upsert_item(body) + _record_cache_event('set', namespace, redis_key, backend='cosmos', result='success') + return True + except Exception as ex: + _record_cache_event('set', namespace, redis_key, backend='cosmos', result='error') + _log_cache_warning('cosmos_set_shared_cache_entry', ex, {'cache_key': redis_key}) + return False + + +def get_shared_cache_entry(namespace, key, redis_client=None, allow_cosmos_fallback=True): + """Read a shared cache entry, optionally falling back to Cosmos when Redis is unavailable.""" + redis_key = _build_redis_key(namespace, key) + if redis_client is not None: + try: + value = redis_client.get(redis_key) + if value is not None: + _record_cache_event('get', namespace, redis_key, backend='redis', result='hit') + return _deserialize_from_cache(value) + _record_cache_event('get', namespace, redis_key, backend='redis', result='miss') + if not allow_cosmos_fallback: + _record_cache_event('get', namespace, redis_key, backend='cosmos', result='skipped') + return None + except Exception as ex: + _record_cache_event('get', namespace, redis_key, backend='redis', result='error') + _log_cache_warning('redis_get_shared_cache_entry', ex, {'cache_key': redis_key}) + if not allow_cosmos_fallback: + _record_cache_event('get', namespace, redis_key, backend='cosmos', result='skipped') + return None + elif not allow_cosmos_fallback: + _record_cache_event('get', namespace, redis_key, backend='cosmos', result='skipped') + return None + + try: + entry = cosmos_settings_container.read_item(item=redis_key, partition_key=redis_key) + if _is_expired(entry): + delete_shared_cache_entry(namespace, key) + _record_cache_event('get', namespace, redis_key, backend='cosmos', result='expired') + return None + _record_cache_event('get', namespace, redis_key, backend='cosmos', result='hit') + return copy.deepcopy(entry.get('value')) + except Exception as ex: + if _is_not_found_error(ex): + _record_cache_event('get', namespace, redis_key, backend='cosmos', result='miss') + return None + _record_cache_event('get', namespace, redis_key, backend='cosmos', result='error') + _log_cache_warning('cosmos_get_shared_cache_entry', ex, {'cache_key': redis_key}) + return None + + +def delete_shared_cache_entry(namespace, key, redis_client=None, allow_cosmos_fallback=True): + """Delete a shared cache entry from Redis and optionally Cosmos without failing callers.""" + redis_key = _build_redis_key(namespace, key) + deleted = True + if redis_client is not None: + try: + redis_client.delete(redis_key) + _record_cache_event('delete', namespace, redis_key, backend='redis', result='success') + if not allow_cosmos_fallback: + _record_cache_event('delete', namespace, redis_key, backend='cosmos', result='skipped') + return deleted + except Exception as ex: + deleted = False + _record_cache_event('delete', namespace, redis_key, backend='redis', result='error') + _log_cache_warning('redis_delete_shared_cache_entry', ex, {'cache_key': redis_key}) + if not allow_cosmos_fallback: + _record_cache_event('delete', namespace, redis_key, backend='cosmos', result='skipped') + return False + elif not allow_cosmos_fallback: + _record_cache_event('delete', namespace, redis_key, backend='cosmos', result='skipped') + return True + + try: + cosmos_settings_container.delete_item(item=redis_key, partition_key=redis_key) + _record_cache_event('delete', namespace, redis_key, backend='cosmos', result='success') + except Exception as ex: + if not _is_not_found_error(ex): + deleted = False + _record_cache_event('delete', namespace, redis_key, backend='cosmos', result='error') + _log_cache_warning('cosmos_delete_shared_cache_entry', ex, {'cache_key': redis_key}) + else: + _record_cache_event('delete', namespace, redis_key, backend='cosmos', result='miss') + return deleted + + +def _get_version_cache_key(container, version_doc_id): + return f"{id(container)}:{version_doc_id}" + + +def _set_cached_shared_cache_version(container, version_doc_id, version): + cache_lock = _get_version_cache_lock() + with cache_lock: + _version_cache[_get_version_cache_key(container, version_doc_id)] = { + 'value': version, + 'expires_at': time.time() + SHARED_CACHE_VERSION_READ_TTL_SECONDS, + } + + +def get_shared_cache_version(version_doc_id, default_version=0, container=None, use_local_cache=True): + """Read a Cosmos-backed shared cache version with a short local TTL.""" + cache_container = container or cosmos_settings_container + cache_key = _get_version_cache_key(cache_container, version_doc_id) + now = time.time() + cache_lock = _get_version_cache_lock() + if use_local_cache: + with cache_lock: + cached = _version_cache.get(cache_key) + if cached and cached.get('expires_at', 0) > now: + _record_cache_event( + 'version_get', + namespace='shared_cache_version', + key=version_doc_id, + backend='local', + result='hit', + ) + return _normalize_cache_version(cached.get('value')) + + try: + doc = cache_container.read_item(item=version_doc_id, partition_key=version_doc_id) + version = _normalize_cache_version(doc.get('version', default_version)) + _record_cache_event( + 'version_get', + namespace='shared_cache_version', + key=version_doc_id, + backend='cosmos', + result='hit', + ) + except Exception as ex: + if not _is_not_found_error(ex): + _record_cache_event( + 'version_get', + namespace='shared_cache_version', + key=version_doc_id, + backend='cosmos', + result='error', + ) + _log_cache_warning('cosmos_get_shared_cache_version', ex, {'version_doc_id': version_doc_id}) + else: + _record_cache_event( + 'version_get', + namespace='shared_cache_version', + key=version_doc_id, + backend='cosmos', + result='miss', + ) + version = _normalize_cache_version(default_version) + + if use_local_cache: + _set_cached_shared_cache_version(cache_container, version_doc_id, version) + return version + + +def ensure_shared_cache_version_doc(version_doc_id, initial_version=0, description='', container=None): + """Create a cache version document if it is missing and return its current state.""" + cache_container = container or cosmos_settings_container + try: + doc = cache_container.read_item(item=version_doc_id, partition_key=version_doc_id) + return { + 'id': version_doc_id, + 'created': False, + 'version': _normalize_cache_version(doc.get('version', initial_version)), + 'description': doc.get('description') or description, + } + except Exception as ex: + if not _is_not_found_error(ex): + _log_cache_warning('cosmos_read_shared_cache_version_doc', ex, {'version_doc_id': version_doc_id}) + raise + + version = _normalize_cache_version(initial_version) + body = { + 'id': version_doc_id, + 'type': SHARED_CACHE_VERSION_DOC_TYPE, + 'description': description, + 'version': version, + 'created_at': _utc_now_iso(), + 'updated_at': _utc_now_iso(), + } + try: + cache_container.create_item(body=body) + return { + 'id': version_doc_id, + 'created': True, + 'version': version, + 'description': description, + } + except Exception as ex: + if getattr(ex, 'status_code', None) == 409: + doc = cache_container.read_item(item=version_doc_id, partition_key=version_doc_id) + return { + 'id': version_doc_id, + 'created': False, + 'version': _normalize_cache_version(doc.get('version', initial_version)), + 'description': doc.get('description') or description, + } + _log_cache_warning('cosmos_create_shared_cache_version_doc', ex, {'version_doc_id': version_doc_id}) + raise + + +def bump_shared_cache_version(version_doc_id, description='', container=None): + """Increment a Cosmos-backed shared cache version document with optimistic concurrency.""" + cache_container = container or cosmos_settings_container + ensure_shared_cache_version_doc(version_doc_id, description=description, container=cache_container) + + last_conflict = None + for _ in range(SHARED_CACHE_VERSION_BUMP_MAX_RETRIES): + try: + doc = cache_container.read_item(item=version_doc_id, partition_key=version_doc_id) + current_version = _normalize_cache_version(doc.get('version')) + next_version = current_version + 1 + replacement_doc = dict(doc) + replacement_doc.update({ + 'id': version_doc_id, + 'type': SHARED_CACHE_VERSION_DOC_TYPE, + 'description': doc.get('description') or description, + 'version': next_version, + 'updated_at': _utc_now_iso(), + }) + cache_container.replace_item( + item=version_doc_id, + body=replacement_doc, + etag=doc.get('_etag'), + match_condition=MatchConditions.IfNotModified, + ) + _set_cached_shared_cache_version(cache_container, version_doc_id, next_version) + _record_cache_event( + 'version_bump', + namespace='shared_cache_version', + key=version_doc_id, + backend='cosmos', + result='success', + extra={'version': next_version}, + ) + return next_version + except Exception as ex: + status_code = getattr(ex, 'status_code', None) + if _is_not_found_error(ex): + ensure_shared_cache_version_doc(version_doc_id, description=description, container=cache_container) + last_conflict = ex + continue + if status_code in (409, 412): + last_conflict = ex + _record_cache_event( + 'version_bump', + namespace='shared_cache_version', + key=version_doc_id, + backend='cosmos', + result='conflict', + ) + continue + _record_cache_event( + 'version_bump', + namespace='shared_cache_version', + key=version_doc_id, + backend='cosmos', + result='error', + ) + _log_cache_warning('cosmos_bump_shared_cache_version', ex, {'version_doc_id': version_doc_id}) + raise + + _log_cache_warning( + 'cosmos_bump_shared_cache_version_conflict', + last_conflict or RuntimeError('Version bump retry limit exceeded.'), + {'version_doc_id': version_doc_id, 'max_retries': SHARED_CACHE_VERSION_BUMP_MAX_RETRIES}, + ) + if last_conflict: + raise last_conflict + raise RuntimeError('Shared cache version bump retry limit exceeded.') diff --git a/application/single_app/functions_simplechat_operations.py b/application/single_app/functions_simplechat_operations.py index 6b6cba7e..10c17d70 100644 --- a/application/single_app/functions_simplechat_operations.py +++ b/application/single_app/functions_simplechat_operations.py @@ -49,6 +49,7 @@ persist_collaboration_message, ) from functions_documents import allowed_file, create_document, process_document_upload_background, update_document +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version from functions_group import ( assert_group_role, check_group_status_allows_operation, @@ -885,6 +886,7 @@ def make_group_inactive_for_current_user( } ) updated_group_doc = cosmos_groups_container.upsert_item(group_doc) + bump_chat_bootstrap_global_cache_version(reason="group_marked_inactive") log_group_status_change( group_id=resolved_group_id, @@ -1106,6 +1108,7 @@ def add_group_member_for_current_user( group_doc["modifiedDate"] = datetime.utcnow().isoformat() updated_group_doc = cosmos_groups_container.upsert_item(group_doc) + bump_chat_bootstrap_global_cache_version(reason="group_member_added") _log_group_member_addition( actor_user=current_user, diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index eecc5763..e73a05c0 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -104,6 +104,7 @@ normalize_chart_kind, user_request_supports_proactive_charts, ) +from functions_conversation_cache import invalidate_conversation_cache_for_item from functions_conversation_metadata import collect_conversation_metadata, update_conversation_with_metadata from functions_conversation_unread import mark_conversation_unread from functions_image_messages import build_image_message_documents, decode_image_content @@ -2263,6 +2264,7 @@ def _create_personal_conversation(user_id, conversation_id=None): conversation_item['added_to_activity_log'] = True cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_created") return conversation_item @@ -11655,6 +11657,7 @@ def _load_or_create_analyze_conversation(user_id, conversation_id=None): ) conversation_item['added_to_activity_log'] = True cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_created") return conversation_item def execute_document_action_chat_request(data=None, publish_background_event=None, forced_action_type=None): @@ -11912,6 +11915,7 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non if title_updated: conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_title_initialized") if callable(publish_background_event): publish_background_event(_build_conversation_metadata_stream_event(conversation_item)) @@ -12191,6 +12195,7 @@ def execute_document_action_chat_request(data=None, publish_background_event=Non debug_print(f'[ChatDocumentAnalysis] Conversation metadata update failed: {exc}') cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="document_action_chat_completed") debug_print( '[ChatDocumentAction] Execution completed | ' f'user_id={user_id} | ' @@ -12419,6 +12424,7 @@ def generate_image_from_proposal(): conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="chat_image_proposal_generated") image_doc = image_result.pop('image_message', {}) or {} image_doc_metadata = image_doc.get('metadata') if isinstance(image_doc.get('metadata'), dict) else {} @@ -13324,6 +13330,7 @@ def result_requires_message_reload(result: Any) -> bool: conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) # Update timestamp and potentially title + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_title_initialized") assistant_message_id, thought_tracker, assistant_thread_attempt, response_message_context = _initialize_assistant_response_tracking( conversation_id=conversation_id, @@ -13432,6 +13439,7 @@ def result_requires_message_reload(result: Any) -> bool: # Update conversation's last_updated conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="chat_safety_blocked") # Return a normal 200 with a special field: blocked=True return jsonify({ @@ -14247,6 +14255,7 @@ def result_requires_message_reload(result: Any) -> bool: conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="chat_image_generated") return jsonify({ 'reply': "Image loading...", @@ -15866,6 +15875,7 @@ def gpt_error(e): # Add any other final updates to conversation_item if needed (like classifications if not done earlier) cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="chat_completed") # --------------------------------------------------------------------- # 8) Return final success (even if AI generated an error message) @@ -16887,6 +16897,7 @@ def build_streaming_capability_usage(): conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_title_initialized") if title_updated: yield _build_conversation_metadata_stream_event(conversation_item) @@ -17056,6 +17067,7 @@ def record_and_publish_streaming_thought(thought_payload): conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="chat_safety_blocked") final_data = make_json_serializable({ 'content': blocked_msg_content.strip(), @@ -18260,6 +18272,7 @@ def finalize_cancelled_stream_response(): cosmos_messages_container.upsert_item(assistant_doc) conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="chat_stream_stopped") message_persisted = True log_event( @@ -18964,6 +18977,7 @@ def finalize_cancelled_agent_stream_response(): ) cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="chat_stream_completed") # Send final message with metadata final_data = make_json_serializable({ diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index 78a09a48..1d00cec8 100644 --- a/application/single_app/route_backend_collaboration.py +++ b/application/single_app/route_backend_collaboration.py @@ -44,6 +44,7 @@ update_personal_collaboration_member_role, update_personal_collaboration_title, ) +from functions_conversation_cache import bump_conversation_cache_version from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id, require_active_group from functions_image_messages import decode_image_content, get_complete_image_content, is_blob_backed_image_message, is_external_image_url from functions_message_masking import ( @@ -1806,6 +1807,7 @@ def mark_collaboration_conversation_read_api(conversation_id): current_user['user_id'], conversation_id, ) + bump_conversation_cache_version(current_user['user_id'], reason="collaboration_marked_read") return jsonify({ 'success': True, diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index 40dba76b..c820fd6b 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -10,6 +10,7 @@ from config import * from functions_authentication import * +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version from functions_settings import * from functions_logging import * from functions_activity_logging import * @@ -3265,6 +3266,7 @@ def api_update_group_status(group_id): # Update in database cosmos_groups_container.upsert_item(group) + bump_chat_bootstrap_global_cache_version(reason="group_status_updated") # Log to activity_logs container for audit trail from functions_activity_logging import log_group_status_change @@ -3708,6 +3710,7 @@ def api_admin_add_group_member(group_id): # Save group cosmos_groups_container.upsert_item(group) + bump_chat_bootstrap_global_cache_version(reason="group_member_added") # Determine the action source (single add vs bulk CSV) source = data.get('source', 'csv') # Default to 'csv' for backward compatibility @@ -4139,6 +4142,7 @@ def api_update_public_workspace_status(workspace_id): # Update in database cosmos_public_workspaces_container.upsert_item(workspace) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_status_updated") # Log to activity_logs container for audit trail from functions_activity_logging import log_public_workspace_status_change @@ -4297,6 +4301,7 @@ def api_bulk_public_workspace_action(): }) cosmos_public_workspaces_container.upsert_item(workspace) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_bulk_status_updated") # Log activity from functions_activity_logging import log_public_workspace_status_change @@ -4527,6 +4532,7 @@ def api_admin_add_workspace_member(workspace_id): # Save workspace cosmos_public_workspaces_container.upsert_item(workspace) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_member_added") # Determine the action source source = data.get('source', 'csv') @@ -4636,6 +4642,7 @@ def api_admin_add_workspace_member_single(workspace_id): # Save workspace cosmos_public_workspaces_container.upsert_item(workspace) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_member_added") # Log to activity logs activity_record = { @@ -7147,6 +7154,7 @@ def _execute_take_ownership(approval, executor_id, executor_email, executor_name group['modifiedDate'] = datetime.utcnow().isoformat() cosmos_groups_container.upsert_item(group) + bump_chat_bootstrap_global_cache_version(reason="group_ownership_transferred") # Log to activity logs activity_record = { @@ -7284,6 +7292,7 @@ def _execute_take_workspace_ownership(approval, executor_id, executor_email, exe workspace['modifiedDate'] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(workspace) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_ownership_transferred") # Log to activity logs activity_record = { @@ -7369,6 +7378,7 @@ def _execute_transfer_ownership(approval, executor_id, executor_email, executor_ group['modifiedDate'] = datetime.utcnow().isoformat() cosmos_groups_container.upsert_item(group) + bump_chat_bootstrap_global_cache_version(reason="group_ownership_transferred") # Log to activity logs activity_record = { @@ -7509,6 +7519,7 @@ def _execute_transfer_workspace_ownership(approval, executor_id, executor_email, workspace['modifiedDate'] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(workspace) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_ownership_transferred") # Log to activity logs activity_record = { diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index cd611726..d53fbb43 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -32,6 +32,14 @@ ) from functions_conversation_metadata import get_conversation_metadata, update_conversation_with_metadata from functions_conversation_unread import clear_conversation_unread, normalize_conversation_unread_state +from functions_conversation_cache import ( + build_conversation_cache_key, + bump_conversation_cache_version, + get_cached_conversation_payload, + get_conversation_cache_settings, + invalidate_conversation_cache_for_item, + set_cached_conversation_payload, +) from functions_image_messages import decode_image_content, get_complete_image_content, hydrate_image_messages, is_blob_backed_image_message, is_external_image_url from functions_notifications import mark_chat_response_notifications_read_for_conversation from flask import Response, request, stream_with_context @@ -40,6 +48,7 @@ delete_chat_upload_workspace_documents_for_conversation, serialize_chat_upload_workspace_documents_for_conversation, ) +from functions_group import get_user_groups from functions_message_artifacts import ( build_message_artifact_payload_map, filter_assistant_artifact_items, @@ -107,6 +116,43 @@ def normalize_chat_type(conversation_item): } +def _build_conversation_cache_access_parameters(user_id): + """Return current group-access inputs that affect collaboration feed/search visibility.""" + try: + group_docs = get_user_groups(user_id) + except Exception as exc: + log_event( + f"[ConversationCache] Failed to build group access cache fingerprint for {user_id}: {exc}", + level=logging.WARNING, + exceptionTraceback=True, + debug_only=True, + ) + return None + + groups = [] + for group_doc in group_docs or []: + if not isinstance(group_doc, dict): + continue + group_id = str(group_doc.get('id') or '').strip() + if not group_id: + continue + groups.append({ + 'id': group_id, + 'status': str(group_doc.get('status') or 'active'), + 'updated_at': str( + group_doc.get('updated_at') + or group_doc.get('last_updated') + or group_doc.get('_ts') + or '' + ), + }) + + groups.sort(key=lambda group_item: group_item['id']) + return { + 'groups': groups, + } + + def _normalize_workspace_document_delete_ids(raw_document_ids): if raw_document_ids is None: return [] @@ -713,6 +759,23 @@ def _authorize_personal_conversation_read(user_id, conversation_id): return conversation_item +def _invalidate_conversation_cache_after_message_mutation(conversation_id, user_id, reason): + """Invalidate conversation caches after message-level changes without failing the caller.""" + try: + conversation_item = _authorize_personal_conversation_read(user_id, conversation_id) + except Exception as exc: + log_event( + f"[ConversationCache] Failed to load conversation {conversation_id} for message mutation invalidation: {exc}", + level=logging.WARNING, + exceptionTraceback=True, + debug_only=True, + ) + bump_conversation_cache_version(user_id, reason=reason) + return + + invalidate_conversation_cache_for_item(conversation_item, reason=reason) + + def _authorize_image_conversation_read(user_id, conversation_id): """Authorize image reads for either personal or collaborative conversations.""" try: @@ -948,12 +1011,34 @@ def get_conversations(): user_id = get_current_user_id() if not user_id: return jsonify({'error': 'User not authenticated'}), 401 - query = f"SELECT * FROM c WHERE c.user_id = '{user_id}' ORDER BY c.last_updated DESC" - items = list(cosmos_conversations_container.query_items(query=query, enable_cross_partition_query=True)) + settings = get_settings() + cache_settings = get_conversation_cache_settings(settings) + cache_key = None + if cache_settings.get('enabled'): + cache_key = build_conversation_cache_key(user_id, "list", parameters={"include_hidden": True}) + if cache_key: + cached_payload = get_cached_conversation_payload(cache_key, settings=settings) + if isinstance(cached_payload, dict) and isinstance(cached_payload.get('conversations'), list): + return jsonify(cached_payload), 200 + + query = "SELECT * FROM c WHERE c.user_id = @user_id ORDER BY c.last_updated DESC" + items = list(cosmos_conversations_container.query_items( + query=query, + parameters=[{'name': '@user_id', 'value': user_id}], + enable_cross_partition_query=True, + )) normalized_items = [normalize_conversation_unread_state(item) for item in items] - return jsonify({ + payload = { 'conversations': normalized_items - }), 200 + } + if cache_key: + set_cached_conversation_payload( + cache_key, + payload, + ttl_seconds=cache_settings.get('ttl_seconds'), + settings=settings, + ) + return jsonify(payload), 200 @bp.route('/api/conversations/feed', methods=['GET']) @@ -978,6 +1063,29 @@ def get_conversations_feed(): source_offsets = get_conversation_feed_source_offsets(cursor_data) if cursor_is_compatible else {} include_priority = not cursor_is_compatible + settings = get_settings() + cache_settings = get_conversation_cache_settings(settings) + access_parameters = _build_conversation_cache_access_parameters(user_id) + feed_cache_parameters = { + "search_term": search_term, + "include_hidden": include_hidden, + "page_size": page_size, + "cursor": request.args.get('cursor') or "", + "include_priority": include_priority, + "access": access_parameters, + } + feed_cache_key = None + if cache_settings.get('enabled') and access_parameters is not None: + feed_cache_key = build_conversation_cache_key( + user_id, + "feed", + parameters=feed_cache_parameters, + ) + if feed_cache_key: + cached_feed_payload = get_cached_conversation_payload(feed_cache_key, settings=settings) + if isinstance(cached_feed_payload, dict) and isinstance(cached_feed_payload.get('conversations'), list): + return jsonify(cached_feed_payload), 200 + feed_payload = _build_conversation_feed( user_id=user_id, page_size=page_size, @@ -988,6 +1096,13 @@ def get_conversations_feed(): ) feed_payload['search_term'] = search_term feed_payload['include_hidden'] = include_hidden + if feed_cache_key: + set_cached_conversation_payload( + feed_cache_key, + feed_payload, + ttl_seconds=cache_settings.get('ttl_seconds'), + settings=settings, + ) return jsonify(feed_payload), 200 except Exception as exc: log_event( @@ -1012,6 +1127,7 @@ def create_conversation(): data.get('initial_message') or data.get('message') or data.get('title') or '' ) conversation_item = create_personal_conversation_for_current_user(title=initial_title) + bump_conversation_cache_version(user_id, reason="conversation_created") return jsonify({ 'conversation_id': conversation_item.get('id'), @@ -1053,6 +1169,7 @@ def update_conversation_title(conversation_id): # Write back to Cosmos DB cosmos_conversations_container.upsert_item(conversation_item) + bump_conversation_cache_version(user_id, reason="conversation_title_updated") return jsonify({ 'message': 'Conversation updated', @@ -1183,6 +1300,7 @@ def delete_conversation(conversation_id): item=conversation_id, partition_key=conversation_id ) + bump_conversation_cache_version(user_id, reason="conversation_deleted") # TODO: Delete any facts that were stored with this conversation. except Exception as e: return jsonify({ @@ -1289,7 +1407,10 @@ def delete_multiple_conversations(): except Exception as e: print(f"Error deleting conversation {conversation_id}: {str(e)}") failed_ids.append(conversation_id) - + + if success_count: + bump_conversation_cache_version(user_id, reason="conversations_bulk_deleted") + return jsonify({ "success": True, "deleted_count": success_count, @@ -1326,6 +1447,7 @@ def toggle_conversation_pin(conversation_id): # Update in Cosmos DB cosmos_conversations_container.upsert_item(conversation_item) + bump_conversation_cache_version(user_id, reason="conversation_pin_toggled") return jsonify({ 'success': True, @@ -1368,6 +1490,7 @@ def toggle_conversation_hide(conversation_id): # Update in Cosmos DB cosmos_conversations_container.upsert_item(conversation_item) + bump_conversation_cache_version(user_id, reason="conversation_hide_toggled") return jsonify({ 'success': True, @@ -1430,7 +1553,10 @@ def bulk_pin_conversations(): except Exception as e: print(f"Error updating conversation {conversation_id}: {str(e)}") failed_ids.append(conversation_id) - + + if success_count: + bump_conversation_cache_version(user_id, reason="conversations_bulk_pin_updated") + return jsonify({ "success": True, "updated_count": success_count, @@ -1488,7 +1614,10 @@ def bulk_hide_conversations(): except Exception as e: print(f"Error updating conversation {conversation_id}: {str(e)}") failed_ids.append(conversation_id) - + + if success_count: + bump_conversation_cache_version(user_id, reason="conversations_bulk_hide_updated") + return jsonify({ "success": True, "updated_count": success_count, @@ -1523,6 +1652,7 @@ def get_conversation_metadata_api(conversation_id): _, updated = normalize_chat_type(conversation_item) if updated: cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_chat_type_normalized") linked_workspace_documents = [] try: @@ -1586,8 +1716,15 @@ def mark_conversation_read_api(conversation_id): if conversation_item.get('user_id') != user_id: return jsonify({'error': 'Forbidden'}), 403 - conversation_item = clear_conversation_unread(conversation_item) - cosmos_conversations_container.upsert_item(conversation_item) + conversation_state_changed = ( + conversation_item.get('has_unread_assistant_response') is True + or conversation_item.get('last_unread_assistant_message_id') is not None + or conversation_item.get('last_unread_assistant_at') is not None + ) + if conversation_state_changed: + conversation_item = clear_conversation_unread(conversation_item) + cosmos_conversations_container.upsert_item(conversation_item) + bump_conversation_cache_version(user_id, reason="conversation_marked_read") notifications_marked_read = mark_chat_response_notifications_read_for_conversation( user_id, @@ -1599,6 +1736,7 @@ def mark_conversation_read_api(conversation_id): 'conversation_id': conversation_id, 'has_unread_assistant_response': False, 'notifications_marked_read': notifications_marked_read, + 'conversation_state_changed': conversation_state_changed, }), 200 except CosmosResourceNotFoundError: return jsonify({'error': 'Conversation not found'}), 404 @@ -1758,6 +1896,7 @@ def patch_conversation_scope_lock(conversation_id): user_id, new_value, ) + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_scope_lock_updated") return jsonify({ "success": True, @@ -1844,6 +1983,34 @@ def search_conversations(): 'success': False, 'error': 'Search term must be at least 3 characters' }), 400 + + settings = get_settings() + cache_settings = get_conversation_cache_settings(settings) + access_parameters = _build_conversation_cache_access_parameters(user_id) + search_cache_parameters = { + 'search_term': search_term, + 'match_mode': match_mode, + 'date_from': date_from, + 'date_to': date_to, + 'chat_types': sorted([str(item) for item in chat_types or []]), + 'classifications': sorted([str(item) for item in classifications or []]), + 'has_files': bool(has_files), + 'has_images': bool(has_images), + 'page': page, + 'per_page': per_page, + 'access': access_parameters, + } + search_cache_key = None + if cache_settings.get('enabled') and access_parameters is not None: + search_cache_key = build_conversation_cache_key( + user_id, + "search", + parameters=search_cache_parameters, + ) + if search_cache_key: + cached_search_payload = get_cached_conversation_payload(search_cache_key, settings=settings) + if isinstance(cached_search_payload, dict) and cached_search_payload.get('success') is True: + return jsonify(cached_search_payload), 200 selected_chat_types = _expand_search_chat_type_filters(chat_types) @@ -2000,14 +2167,22 @@ def search_conversations(): end_idx = start_idx + per_page paginated_results = results[start_idx:end_idx] - return jsonify({ + payload = { 'success': True, 'total_results': total_results, 'page': page, 'total_pages': total_pages, 'per_page': per_page, 'results': paginated_results - }), 200 + } + if search_cache_key: + set_cached_conversation_payload( + search_cache_key, + payload, + ttl_seconds=cache_settings.get('ttl_seconds'), + settings=settings, + ) + return jsonify(payload), 200 except Exception as e: log_event( @@ -2281,7 +2456,12 @@ def delete_message(message_id): cosmos_messages_container.delete_item(msg_id, partition_key=conversation_id) deleted_message_ids.append(msg_id) - + + _invalidate_conversation_cache_after_message_mutation( + conversation_id, + user_id, + "message_deleted", + ) return jsonify({ 'success': True, 'deleted_message_ids': deleted_message_ids, @@ -2454,7 +2634,12 @@ def retry_message(message_id): 'metadata': new_metadata } cosmos_messages_container.upsert_item(new_user_message) - + + _invalidate_conversation_cache_after_message_mutation( + conversation_id, + user_id, + "message_retry_created", + ) # Build chat request parameters from original message metadata chat_request = { 'message': user_content, @@ -2671,7 +2856,12 @@ def edit_message(message_id): 'metadata': new_metadata } cosmos_messages_container.upsert_item(new_user_message) - + + _invalidate_conversation_cache_after_message_mutation( + conversation_id, + user_id, + "message_edit_created", + ) # Build chat request parameters from original message metadata # Keep all original settings (model, reasoning, doc search, etc.) chat_request = { @@ -2833,7 +3023,12 @@ def switch_attempt(message_id): msg_attempt = msg['metadata']['thread_info'].get('thread_attempt', 0) msg['metadata']['thread_info']['active_thread'] = (msg_attempt == target_attempt) cosmos_messages_container.upsert_item(msg) - + + _invalidate_conversation_cache_after_message_mutation( + conversation_id, + user_id, + "message_attempt_switched", + ) return jsonify({ 'success': True, 'target_attempt': target_attempt, diff --git a/application/single_app/route_backend_documents.py b/application/single_app/route_backend_documents.py index 47bf2933..f7d0a6b7 100644 --- a/application/single_app/route_backend_documents.py +++ b/application/single_app/route_backend_documents.py @@ -12,6 +12,14 @@ build_synced_document_delete_guard, ) from functions_notifications import create_notification, delete_notifications_by_metadata +from functions_document_access_index import ( + DOCUMENT_ACCESS_SCOPE_PERSONAL, + is_document_access_shadow_validation_enabled, + query_items_with_cosmos_diagnostics, + query_document_access_index_documents, + query_document_access_index_legacy_count, + query_document_access_index_tag_counts, +) from utils_cache import invalidate_personal_search_cache from functions_debug import * from functions_activity_logging import log_document_upload, log_document_metadata_update_transaction @@ -694,6 +702,7 @@ def api_get_user_documents(): # Add user_id prefix for shared_user_ids with status user_id_prefix = f"{user_id}," query_params.append({"name": "@user_id_prefix", "value": user_id_prefix}) + shadow_tags_filter = [] # Replace the main ownership/shared condition query_conditions[0] = ( @@ -753,6 +762,7 @@ def api_get_user_documents(): tags_list = sanitize_tags_for_filter(tags_filter) if tags_list: + shadow_tags_filter = tags_list # Each tag must exist in the document's tags array for idx, tag in enumerate(tags_list): param_name = f"@tag_{param_count}_{idx}" @@ -762,6 +772,17 @@ def api_get_user_documents(): # Combine conditions into the WHERE clause where_clause = " AND ".join(query_conditions) + shadow_filters = { + 'search': search_term, + 'classification': classification_filter, + 'classification_none_matches_literal': True, + 'author': author_filter, + 'keywords': keywords_filter, + 'abstract': abstract_filter, + 'tags': shadow_tags_filter, + 'array_match_mode': 'contains', + } + used_document_access_index = False # --- 3) Query matching documents, then collapse to current revisions before paginating --- try: @@ -771,17 +792,71 @@ def api_get_user_documents(): FROM c WHERE {where_clause} """ - matching_docs = list(cosmos_user_documents_container.query_items( - query=data_query_str, - parameters=query_params, - enable_cross_partition_query=True - )) - - current_docs = sort_documents( - select_current_documents(matching_docs), - sort_by=sort_by, - sort_order=sort_order, + index_read_result = query_document_access_index_documents( + source_scope=DOCUMENT_ACCESS_SCOPE_PERSONAL, + user_id=user_id, + filters=shadow_filters, ) + + if index_read_result.get('success'): + used_document_access_index = True + current_docs = sort_documents( + index_read_result.get('documents', []), + sort_by=sort_by, + sort_order=sort_order, + ) + if is_document_access_shadow_validation_enabled(): + try: + matching_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_user_documents_container, + diagnostics_label='source_documents', + query=data_query_str, + parameters=query_params, + enable_cross_partition_query=True + ) + source_current_docs = sort_documents( + select_current_documents(matching_docs), + sort_by=sort_by, + sort_order=sort_order, + ) + validate_document_access_index_shadow( + source_current_docs, + source_scope=DOCUMENT_ACCESS_SCOPE_PERSONAL, + user_id=user_id, + filters=shadow_filters, + source_query_metrics=source_query_metrics, + context='api_get_user_documents', + ) + except Exception as shadow_error: + log_event( + '[DocumentAccessIndex] Shadow validation source query failed after DAI read succeeded.', + extra={'source_scope': DOCUMENT_ACCESS_SCOPE_PERSONAL, 'error': str(shadow_error)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + else: + matching_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_user_documents_container, + diagnostics_label='source_documents', + collect_diagnostics=is_document_access_shadow_validation_enabled(), + query=data_query_str, + parameters=query_params, + enable_cross_partition_query=True + ) + + current_docs = sort_documents( + select_current_documents(matching_docs), + sort_by=sort_by, + sort_order=sort_order, + ) + validate_document_access_index_shadow( + current_docs, + source_scope=DOCUMENT_ACCESS_SCOPE_PERSONAL, + user_id=user_id, + filters=shadow_filters, + source_query_metrics=source_query_metrics, + context='api_get_user_documents', + ) total_count = len(current_docs) docs = current_docs[offset:offset + page_size] @@ -803,23 +878,32 @@ def api_get_user_documents(): # --- new: do we have any legacy documents? --- - try: - legacy_q = """ - SELECT VALUE COUNT(1) - FROM c - WHERE c.user_id = @user_id - AND NOT IS_DEFINED(c.percentage_complete) - """ - legacy_docs = list( - cosmos_user_documents_container.query_items( - query=legacy_q, - parameters=[{"name":"@user_id","value":user_id}], - enable_cross_partition_query=True - ) + legacy_count = 0 + if used_document_access_index: + legacy_count_result = query_document_access_index_legacy_count( + source_scope=DOCUMENT_ACCESS_SCOPE_PERSONAL, + user_id=user_id, ) - legacy_count = legacy_docs[0] if legacy_docs else 0 - except Exception as e: - debug_print(f"Error executing legacy query: {e}") + if legacy_count_result.get('success'): + legacy_count = legacy_count_result.get('legacy_count', 0) + else: + try: + legacy_q = """ + SELECT VALUE COUNT(1) + FROM c + WHERE c.user_id = @user_id + AND NOT IS_DEFINED(c.percentage_complete) + """ + legacy_docs = list( + cosmos_user_documents_container.query_items( + query=legacy_q, + parameters=[{"name":"@user_id","value":user_id}], + enable_cross_partition_query=True + ) + ) + legacy_count = legacy_docs[0] if legacy_docs else 0 + except Exception as e: + debug_print(f"Error executing legacy query: {e}") # --- 5) Return results --- file_downloads_enabled = is_personal_workspace_file_download_enabled(get_settings()) @@ -1349,10 +1433,20 @@ def api_get_workspace_tags(): if not user_id: return jsonify({'error': 'User not authenticated'}), 401 - from functions_documents import get_workspace_tags + from functions_documents import build_workspace_tags_from_counts, get_workspace_tags try: - tags = get_workspace_tags(user_id) + index_tag_result = query_document_access_index_tag_counts( + DOCUMENT_ACCESS_SCOPE_PERSONAL, + user_id=user_id, + ) + if index_tag_result.get('success'): + tags = build_workspace_tags_from_counts( + index_tag_result.get('tag_counts', {}), + user_id, + ) + else: + tags = get_workspace_tags(user_id) return jsonify({'tags': tags}), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @@ -2115,17 +2209,22 @@ def api_approve_shared_document(document_id): if updated: document_item['shared_user_ids'] = new_shared_user_ids document_item['last_updated'] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') - cosmos_user_documents_container.upsert_item(document_item) + persisted_document = cosmos_user_documents_container.upsert_item(document_item) + document_for_sync = persisted_document if isinstance(persisted_document, dict) else document_item + sync_document_access_index_for_document_fail_open( + document_for_sync, + operation='document_share_approved', + ) # Update all chunks with the new shared_user_ids try: - chunks = get_all_chunks(document_id, document_item.get('user_id')) + chunks = get_all_chunks(document_id, document_for_sync.get('user_id')) for chunk in chunks: chunk_id = chunk.get('id') if chunk_id: try: update_chunk_metadata( chunk_id=chunk_id, - user_id=document_item.get('user_id'), + user_id=document_for_sync.get('user_id'), group_id=None, public_workspace_id=None, document_id=document_id, diff --git a/application/single_app/route_backend_group_documents.py b/application/single_app/route_backend_group_documents.py index af6d69ac..386a976a 100644 --- a/application/single_app/route_backend_group_documents.py +++ b/application/single_app/route_backend_group_documents.py @@ -13,6 +13,15 @@ build_synced_document_delete_guard, ) from functions_notifications import create_notification, delete_notifications_by_metadata +from functions_document_access_index import ( + DOCUMENT_ACCESS_SCOPE_GROUP, + build_document_access_scope_key, + is_document_access_shadow_validation_enabled, + query_items_with_cosmos_diagnostics, + query_document_access_index_documents, + query_document_access_index_legacy_count, + query_document_access_index_tag_counts, +) from functions_simplechat_operations import download_blob_content, queue_generated_document_processing from utils_cache import invalidate_group_search_cache from functions_debug import * @@ -503,6 +512,7 @@ def api_get_group_documents(): query_conditions = [group_condition] param_count = 0 + shadow_tags_filter = [] if search_term: param_name = f"@search_term_{param_count}" @@ -541,6 +551,7 @@ def api_get_group_documents(): from functions_documents import sanitize_tags_for_filter tags_list = sanitize_tags_for_filter(tags_filter) if tags_list: + shadow_tags_filter = tags_list for idx, tag in enumerate(tags_list): param_name = f"@tag_{param_count}_{idx}" query_conditions.append(f"ARRAY_CONTAINS(c.tags, {param_name})") @@ -550,6 +561,17 @@ def api_get_group_documents(): where_clause = " AND ".join(query_conditions) # --- 3) Query matching documents, then collapse to current revisions before paginating --- + shadow_filters = { + 'search': search_term, + 'classification': classification_filter, + 'classification_none_matches_literal': False, + 'author': author_filter, + 'keywords': keywords_filter, + 'abstract': abstract_filter, + 'tags': shadow_tags_filter, + 'array_match_mode': 'contains', + } + used_document_access_index = False try: offset = (page - 1) * page_size data_query_str = f""" @@ -557,16 +579,70 @@ def api_get_group_documents(): FROM c WHERE {where_clause} """ - matching_docs = list(cosmos_group_documents_container.query_items( - query=data_query_str, - parameters=query_params, - enable_cross_partition_query=True - )) - current_docs = sort_documents( - select_current_documents(matching_docs), - sort_by=sort_by, - sort_order=sort_order, + index_read_result = query_document_access_index_documents( + source_scope=DOCUMENT_ACCESS_SCOPE_GROUP, + group_ids=validated_group_ids, + filters=shadow_filters, ) + + if index_read_result.get('success'): + used_document_access_index = True + current_docs = sort_documents( + index_read_result.get('documents', []), + sort_by=sort_by, + sort_order=sort_order, + ) + if is_document_access_shadow_validation_enabled(): + try: + matching_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_group_documents_container, + diagnostics_label='source_documents', + query=data_query_str, + parameters=query_params, + enable_cross_partition_query=True + ) + source_current_docs = sort_documents( + select_current_documents(matching_docs), + sort_by=sort_by, + sort_order=sort_order, + ) + validate_document_access_index_shadow( + source_current_docs, + source_scope=DOCUMENT_ACCESS_SCOPE_GROUP, + group_ids=validated_group_ids, + filters=shadow_filters, + source_query_metrics=source_query_metrics, + context='api_get_group_documents', + ) + except Exception as shadow_error: + log_event( + '[DocumentAccessIndex] Shadow validation source query failed after DAI read succeeded.', + extra={'source_scope': DOCUMENT_ACCESS_SCOPE_GROUP, 'error': str(shadow_error)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + else: + matching_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_group_documents_container, + diagnostics_label='source_documents', + collect_diagnostics=is_document_access_shadow_validation_enabled(), + query=data_query_str, + parameters=query_params, + enable_cross_partition_query=True + ) + current_docs = sort_documents( + select_current_documents(matching_docs), + sort_by=sort_by, + sort_order=sort_order, + ) + validate_document_access_index_shadow( + current_docs, + source_scope=DOCUMENT_ACCESS_SCOPE_GROUP, + group_ids=validated_group_ids, + filters=shadow_filters, + source_query_metrics=source_query_metrics, + context='api_get_group_documents', + ) total_count = len(current_docs) docs = current_docs[offset:offset + page_size] @@ -602,26 +678,16 @@ def api_get_group_documents(): # --- new: do we have any legacy documents? --- - legacy_count = 0 - try: - if len(validated_group_ids) == 1: - legacy_q = """ - SELECT VALUE COUNT(1) - FROM c - WHERE c.group_id = @group_id - AND NOT IS_DEFINED(c.percentage_complete) - """ - legacy_docs = list( - cosmos_group_documents_container.query_items( - query=legacy_q, - parameters=[{"name":"@group_id","value":validated_group_ids[0]}], - enable_cross_partition_query=True - ) - ) - legacy_count = legacy_docs[0] if legacy_docs else 0 - else: - # For multi-group, check each group - for gid in validated_group_ids: + if used_document_access_index: + legacy_count_result = query_document_access_index_legacy_count( + source_scope=DOCUMENT_ACCESS_SCOPE_GROUP, + group_ids=validated_group_ids, + ) + legacy_count = legacy_count_result.get('legacy_count', 0) if legacy_count_result.get('success') else 0 + else: + legacy_count = 0 + try: + if len(validated_group_ids) == 1: legacy_q = """ SELECT VALUE COUNT(1) FROM c @@ -631,13 +697,30 @@ def api_get_group_documents(): legacy_docs = list( cosmos_group_documents_container.query_items( query=legacy_q, - parameters=[{"name":"@group_id","value":gid}], + parameters=[{"name":"@group_id","value":validated_group_ids[0]}], enable_cross_partition_query=True ) ) - legacy_count += legacy_docs[0] if legacy_docs else 0 - except Exception as e: - print(f"Error executing legacy query: {e}") + legacy_count = legacy_docs[0] if legacy_docs else 0 + else: + # For multi-group, check each group + for gid in validated_group_ids: + legacy_q = """ + SELECT VALUE COUNT(1) + FROM c + WHERE c.group_id = @group_id + AND NOT IS_DEFINED(c.percentage_complete) + """ + legacy_docs = list( + cosmos_group_documents_container.query_items( + query=legacy_q, + parameters=[{"name":"@group_id","value":gid}], + enable_cross_partition_query=True + ) + ) + legacy_count += legacy_docs[0] if legacy_docs else 0 + except Exception as e: + print(f"Error executing legacy query: {e}") # --- 5) Return results --- app_settings = get_settings() @@ -1904,9 +1987,10 @@ def api_get_group_document_tags(): except (ValueError, LookupError, PermissionError): group_ids = [] - from functions_documents import get_workspace_tags + from functions_documents import build_workspace_tags_from_counts, get_workspace_tags all_tags = {} + validated_group_ids = [] for gid in group_ids: group_doc = find_group_by_id(gid) if not group_doc: @@ -1914,8 +1998,23 @@ def api_get_group_document_tags(): role = get_user_role_in_group(group_doc, user_id) if not role: continue - - tags = get_workspace_tags(user_id, group_id=gid) + validated_group_ids.append(gid) + + index_tag_result = query_document_access_index_tag_counts( + DOCUMENT_ACCESS_SCOPE_GROUP, + group_ids=validated_group_ids, + ) if validated_group_ids else {'success': False} + + for gid in validated_group_ids: + if index_tag_result.get('success'): + scope_key = build_document_access_scope_key(DOCUMENT_ACCESS_SCOPE_GROUP, gid) + tags = build_workspace_tags_from_counts( + index_tag_result.get('tag_counts_by_scope_key', {}).get(scope_key, {}), + user_id, + group_id=gid, + ) + else: + tags = get_workspace_tags(user_id, group_id=gid) for tag in tags: if tag['name'] in all_tags: all_tags[tag['name']]['count'] += tag['count'] diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py index 627ebea1..05d4188b 100644 --- a/application/single_app/route_backend_groups.py +++ b/application/single_app/route_backend_groups.py @@ -2,6 +2,7 @@ from config import * from functions_authentication import * +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version from functions_group import * from functions_debug import debug_print from functions_notifications import create_notification @@ -263,6 +264,7 @@ def api_update_group_download_settings(group_id): group_doc["modifiedDate"] = datetime.utcnow().isoformat() try: cosmos_groups_container.upsert_item(group_doc) + bump_chat_bootstrap_global_cache_version(reason="group_updated") except exceptions.CosmosHttpResponseError as ex: return jsonify({"error": str(ex)}), 400 @@ -337,6 +339,7 @@ def api_update_group(group_id): except exceptions.CosmosHttpResponseError as ex: return jsonify({"error": str(ex)}), 400 + bump_chat_bootstrap_global_cache_version(reason="group_updated") return jsonify({"message": "Group updated", "id": group_id}), 200 @bp.route("/api/groups//logo", methods=["GET"]) @@ -557,6 +560,8 @@ def approve_reject_request(group_id, request_id): group_doc["pendingUsers"] = pending_list group_doc["modifiedDate"] = datetime.utcnow().isoformat() cosmos_groups_container.upsert_item(group_doc) + if action == "approve": + bump_chat_bootstrap_global_cache_version(reason="group_member_request_approved") return jsonify({"message": msg}), 200 @@ -634,6 +639,7 @@ def remove_member(group_id, member_id): cosmos_groups_container.upsert_item(group_doc) if removed: + bump_chat_bootstrap_global_cache_version(reason="group_member_removed") # Log activity for self-removal from functions_activity_logging import log_group_member_deleted user_email = user_info.get("email", "unknown") @@ -686,6 +692,7 @@ def remove_member(group_id, member_id): cosmos_groups_container.upsert_item(group_doc) if removed: + bump_chat_bootstrap_global_cache_version(reason="group_member_removed") # Log activity for admin/owner removal from functions_activity_logging import log_group_member_deleted user_email = user_info.get("email", "unknown") @@ -767,6 +774,7 @@ def update_member_role(group_id, member_id): group_doc["modifiedDate"] = datetime.utcnow().isoformat() cosmos_groups_container.upsert_item(group_doc) + bump_chat_bootstrap_global_cache_version(reason="group_member_role_updated") # Log activity for role change try: @@ -935,6 +943,7 @@ def transfer_ownership(group_id): group_doc["modifiedDate"] = datetime.utcnow().isoformat() cosmos_groups_container.upsert_item(group_doc) + bump_chat_bootstrap_global_cache_version(reason="group_ownership_transferred") return jsonify({"message": "Ownership transferred successfully"}), 200 diff --git a/application/single_app/route_backend_notifications.py b/application/single_app/route_backend_notifications.py index defd4305..3754d0d1 100644 --- a/application/single_app/route_backend_notifications.py +++ b/application/single_app/route_backend_notifications.py @@ -2,6 +2,7 @@ from config import * from functions_authentication import * +from functions_conversation_cache import bump_conversation_cache_version from functions_settings import * from functions_notifications import * from swagger_wrapper import swagger_route, get_auth_security @@ -120,6 +121,7 @@ def api_mark_notification_read(notification_id): success = mark_notification_read(notification_id, user_id) if success: + bump_conversation_cache_version(user_id, reason="notification_marked_read") return jsonify({ 'success': True, 'message': 'Notification marked as read' @@ -150,6 +152,7 @@ def api_dismiss_notification(notification_id): success = dismiss_notification(notification_id, user_id) if success: + bump_conversation_cache_version(user_id, reason="notification_dismissed") return jsonify({ 'success': True, 'message': 'Notification dismissed' @@ -178,6 +181,8 @@ def api_mark_all_read(): try: user_id = get_current_user_id() count = mark_all_read(user_id) + if count: + bump_conversation_cache_version(user_id, reason="notifications_marked_read") return jsonify({ 'success': True, diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 4e3d983e..a4acbba3 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -4,7 +4,7 @@ import re import builtins import json -from azure.cosmos import CosmosClient +import azure.cosmos as azure_cosmos from azure.cosmos.exceptions import CosmosHttpResponseError from azure.identity import DefaultAzureCredential from flask import Blueprint, jsonify, request, current_app, session @@ -1941,7 +1941,7 @@ def capture_response_headers(response_headers, _): headers.clear() headers.update(response_headers) - client = CosmosClient( + client = azure_cosmos.CosmosClient( endpoint, credential=DefaultAzureCredential() if auth_type == 'identity' else auth_key, timeout=timeout, diff --git a/application/single_app/route_backend_public_documents.py b/application/single_app/route_backend_public_documents.py index a39ba286..f9da07f4 100644 --- a/application/single_app/route_backend_public_documents.py +++ b/application/single_app/route_backend_public_documents.py @@ -1,5 +1,6 @@ # route_backend_public_documents.py +import logging from datetime import datetime, timezone from config import * @@ -8,6 +9,17 @@ from functions_settings import * from functions_public_workspaces import * from functions_documents import * +from functions_appinsights import log_event +from functions_document_access_index import ( + DOCUMENT_ACCESS_SCOPE_PUBLIC, + build_document_access_scope_key, + is_document_access_shadow_validation_enabled, + query_document_access_index_documents, + query_document_access_index_legacy_count, + query_document_access_index_tag_counts, + query_items_with_cosmos_diagnostics, + validate_document_access_index_shadow, +) from functions_file_sync import ( FILE_SYNC_SCOPE_PUBLIC, apply_synced_document_delete_action, @@ -205,6 +217,7 @@ def api_list_public_documents(): conds = ['c.public_workspace_id = @ws'] params = [{'name':'@ws','value':active_ws}] param_count = 0 + shadow_tags_filter = [] if search: conds.append('(CONTAINS(LOWER(c.file_name), LOWER(@search)) OR CONTAINS(LOWER(c.title), LOWER(@search)))') params.append({'name':'@search','value':search}) @@ -241,6 +254,7 @@ def api_list_public_documents(): from functions_documents import sanitize_tags_for_filter tags_list = sanitize_tags_for_filter(tags_filter) if tags_list: + shadow_tags_filter = tags_list for idx, tag in enumerate(tags_list): param_name = f"@tag_{param_count}_{idx}" conds.append(f"ARRAY_CONTAINS(c.tags, {param_name})") @@ -248,27 +262,110 @@ def api_list_public_documents(): param_count += len(tags_list) where = ' AND '.join(conds) + shadow_filters = { + 'search': search, + 'classification': classification_filter, + 'classification_none_matches_literal': True, + 'author': author_filter, + 'keywords': keywords_filter, + 'abstract': abstract_filter, + 'tags': shadow_tags_filter, + 'array_match_mode': 'contains', + } + used_document_access_index = False data_q = f'SELECT * FROM c WHERE {where}' - matching_docs = list(cosmos_public_documents_container.query_items( - query=data_q, parameters=params, enable_cross_partition_query=True - )) - current_docs = sort_documents( - select_current_documents(matching_docs), - sort_by=sort_by, - sort_order=sort_order, - ) - total_count = len(current_docs) - docs = current_docs[offset:offset + page_size] + try: + index_read_result = query_document_access_index_documents( + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + public_workspace_id=active_ws, + filters=shadow_filters, + ) + if index_read_result.get('success'): + used_document_access_index = True + current_docs = sort_documents( + index_read_result.get('documents', []), + sort_by=sort_by, + sort_order=sort_order, + ) + if is_document_access_shadow_validation_enabled(): + try: + matching_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_public_documents_container, + diagnostics_label='source_documents', + query=data_q, + parameters=params, + enable_cross_partition_query=True, + ) + source_current_docs = sort_documents( + select_current_documents(matching_docs), + sort_by=sort_by, + sort_order=sort_order, + ) + validate_document_access_index_shadow( + source_current_docs, + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + user_id=user_id, + public_workspace_id=active_ws, + filters=shadow_filters, + source_query_metrics=source_query_metrics, + context='api_list_public_documents', + ) + except Exception as shadow_error: + log_event( + '[DocumentAccessIndex] Shadow validation source query failed after DAI read succeeded.', + extra={'source_scope': DOCUMENT_ACCESS_SCOPE_PUBLIC, 'error': str(shadow_error)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + else: + matching_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_public_documents_container, + diagnostics_label='source_documents', + collect_diagnostics=is_document_access_shadow_validation_enabled(), + query=data_q, + parameters=params, + enable_cross_partition_query=True, + ) + current_docs = sort_documents( + select_current_documents(matching_docs), + sort_by=sort_by, + sort_order=sort_order, + ) + validate_document_access_index_shadow( + current_docs, + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + user_id=user_id, + public_workspace_id=active_ws, + filters=shadow_filters, + source_query_metrics=source_query_metrics, + context='api_list_public_documents', + ) + total_count = len(current_docs) + docs = current_docs[offset:offset + page_size] + except Exception as e: + log_event( + '[PublicDocuments] Error fetching public documents.', + extra={'public_workspace_id': active_ws, 'error': str(e)}, + level=logging.ERROR, + ) + return jsonify({'error': f'Error fetching documents: {str(e)}'}), 500 # legacy - legacy_q = 'SELECT VALUE COUNT(1) FROM c WHERE c.public_workspace_id = @ws AND NOT IS_DEFINED(c.percentage_complete)' - legacy = list(cosmos_public_documents_container.query_items( - query=legacy_q, - parameters=[{'name':'@ws','value':active_ws}], - enable_cross_partition_query=True - )) - legacy_count = legacy[0] if legacy else 0 + if used_document_access_index: + legacy_count_result = query_document_access_index_legacy_count( + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + public_workspace_id=active_ws, + ) + legacy_count = legacy_count_result.get('legacy_count', 0) if legacy_count_result.get('success') else 0 + else: + legacy_q = 'SELECT VALUE COUNT(1) FROM c WHERE c.public_workspace_id = @ws AND NOT IS_DEFINED(c.percentage_complete)' + legacy = list(cosmos_public_documents_container.query_items( + query=legacy_q, + parameters=[{'name':'@ws','value':active_ws}], + enable_cross_partition_query=True + )) + legacy_count = legacy[0] if legacy else 0 file_downloads_enabled = is_public_workspace_file_download_enabled(get_settings(), ws_doc) return jsonify({ @@ -320,14 +417,64 @@ def api_list_public_workspace_documents(): workspace_conditions = " OR ".join([f"c.public_workspace_id = @ws_{i}" for i in range(len(workspace_ids))]) query = f'SELECT * FROM c WHERE {workspace_conditions} ORDER BY c._ts DESC' params = [{'name': f'@ws_{i}', 'value': workspace_id} for i, workspace_id in enumerate(workspace_ids)] - - docs = list(cosmos_public_documents_container.query_items( - query=query, - parameters=params, - enable_cross_partition_query=True - )) + try: + index_read_result = query_document_access_index_documents( + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + public_workspace_ids=workspace_ids, + ) + if index_read_result.get('success'): + docs = sort_documents(index_read_result.get('documents', [])) + if is_document_access_shadow_validation_enabled(): + try: + source_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_public_documents_container, + diagnostics_label='source_documents', + query=query, + parameters=params, + enable_cross_partition_query=True, + ) + validate_document_access_index_shadow( + sort_documents(select_current_documents(source_docs)), + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + user_id=user_id, + public_workspace_ids=workspace_ids, + source_query_metrics=source_query_metrics, + context='api_list_public_workspace_documents', + ) + except Exception as shadow_error: + log_event( + '[DocumentAccessIndex] Shadow validation source query failed after DAI read succeeded.', + extra={'source_scope': DOCUMENT_ACCESS_SCOPE_PUBLIC, 'error': str(shadow_error)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + else: + source_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_public_documents_container, + diagnostics_label='source_documents', + collect_diagnostics=is_document_access_shadow_validation_enabled(), + query=query, + parameters=params, + enable_cross_partition_query=True, + ) + docs = sort_documents(select_current_documents(source_docs)) + validate_document_access_index_shadow( + docs, + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + user_id=user_id, + public_workspace_ids=workspace_ids, + source_query_metrics=source_query_metrics, + context='api_list_public_workspace_documents', + ) + except Exception as e: + log_event( + '[PublicDocuments] Error fetching public workspace chat documents.', + extra={'workspace_count': len(workspace_ids), 'error': str(e)}, + level=logging.ERROR, + ) + return jsonify({'error': f'Error fetching documents: {str(e)}'}), 500 - docs = sort_documents(select_current_documents(docs))[:page_size] + docs = docs[:page_size] return jsonify({ 'documents': docs, @@ -1005,11 +1152,24 @@ def api_get_public_workspace_document_tags(): visible_ids = set(get_user_visible_public_workspace_ids_from_settings(user_id)) validated_ids = [wid for wid in workspace_ids if wid in visible_ids] - from functions_documents import get_workspace_tags + from functions_documents import build_workspace_tags_from_counts, get_workspace_tags + + index_tag_result = query_document_access_index_tag_counts( + DOCUMENT_ACCESS_SCOPE_PUBLIC, + public_workspace_ids=validated_ids, + ) if validated_ids else {'success': False} all_tags = {} for wid in validated_ids: - tags = get_workspace_tags(user_id, public_workspace_id=wid) + if index_tag_result.get('success'): + scope_key = build_document_access_scope_key(DOCUMENT_ACCESS_SCOPE_PUBLIC, wid) + tags = build_workspace_tags_from_counts( + index_tag_result.get('tag_counts_by_scope_key', {}).get(scope_key, {}), + user_id, + public_workspace_id=wid, + ) + else: + tags = get_workspace_tags(user_id, public_workspace_id=wid) for tag in tags: if tag['name'] in all_tags: all_tags[tag['name']]['count'] += tag['count'] diff --git a/application/single_app/route_backend_public_workspaces.py b/application/single_app/route_backend_public_workspaces.py index 8aba7bdf..c74e8b03 100644 --- a/application/single_app/route_backend_public_workspaces.py +++ b/application/single_app/route_backend_public_workspaces.py @@ -2,6 +2,7 @@ from config import * from functions_authentication import * +from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version from functions_prompts import count_public_prompts_for_workspace from functions_public_workspaces import * from functions_settings import ( @@ -330,6 +331,7 @@ def api_update_public_workspace(ws_id): try: cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_updated") return jsonify({"message": "Updated"}), 200 except exceptions.CosmosHttpResponseError as ex: return jsonify({"error": str(ex)}), 400 @@ -558,6 +560,8 @@ def api_handle_public_request(ws_id, req_id): ws["pendingDocumentManagers"] = pend ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + if action == "approve": + bump_chat_bootstrap_global_cache_version(reason="public_workspace_member_request_approved") return jsonify({"message": msg}), 200 @bp.route("/api/public_workspaces//members", methods=["GET"]) @@ -679,6 +683,7 @@ def api_add_public_member(ws_id): }) ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_member_added") # Send notification to the added member try: @@ -740,6 +745,7 @@ def api_remove_public_member(ws_id, member_id): ] ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_member_removed") return jsonify({"success": True, "message": "Removed"}), 200 @bp.route("/api/public_workspaces//members/", methods=["PATCH"]) @@ -820,6 +826,7 @@ def api_update_public_member_role(ws_id, member_id): ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_member_role_updated") # Send notification to the member whose role changed try: @@ -916,6 +923,7 @@ def api_transfer_public_ownership(ws_id): ws["modifiedDate"] = datetime.utcnow().isoformat() cosmos_public_workspaces_container.upsert_item(ws) + bump_chat_bootstrap_global_cache_version(reason="public_workspace_ownership_transferred") return jsonify({"message": "Ownership transferred"}), 200 @bp.route("/api/public_workspaces//fileCount", methods=["GET"]) @@ -1259,4 +1267,3 @@ def api_public_workspace_activity(ws_id): traceback.print_exc() return jsonify(activities), 200 - diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index 9f1ccaa3..5d20fa3a 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -28,6 +28,12 @@ normalize_cosmos_throughput_settings, set_database_throughput, ) +from functions_app_maintenance import get_app_maintenance_status, run_app_maintenance_once +from functions_redis_monitoring import ( + get_redis_explorer_keys, + get_redis_explorer_value, + get_redis_monitoring_status, +) from azure.identity import DefaultAzureCredential from azure.keyvault.secrets import SecretClient from swagger_wrapper import swagger_route, get_auth_security @@ -195,6 +201,83 @@ def auto_fix_index_fields(idx_type: str, user_id: str = 'system', admin_email: s def register_route_backend_settings(bp): + @bp.route('/api/admin/settings/app-maintenance/status', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_app_maintenance_admin_status(): + """Return the current app maintenance status for admin diagnostics.""" + try: + return jsonify(get_app_maintenance_status(settings=get_settings())), 200 + except Exception as exc: + log_event( + '[AppMaintenance] Failed to load admin maintenance status.', + extra={'error': str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load app maintenance status.'}), 500 + + @bp.route('/api/admin/settings/app-maintenance/run', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def run_app_maintenance_admin(): + """Run app maintenance tasks immediately from the admin settings area.""" + user = session.get('user', {}) + admin_email = user.get('preferred_username', user.get('email', 'unknown')) + admin_user_id = get_current_user_id() or 'unknown' + try: + payload = request.get_json(silent=True) or {} + apply_indexing_policies = None + if 'apply_cosmos_indexing_policies' in payload: + apply_indexing_policies = bool(payload.get('apply_cosmos_indexing_policies')) + apply_stale_cache_cleanup = None + if 'apply_stale_cache_cleanup' in payload: + apply_stale_cache_cleanup = bool(payload.get('apply_stale_cache_cleanup')) + result = run_app_maintenance_once( + triggered_by='admin_manual', + requested_by=admin_email, + settings=get_settings(), + apply_indexing_policies=apply_indexing_policies, + run_document_access_backfill=payload.get('run_document_access_index_backfill'), + reset_document_access_backfill=bool(payload.get('reset_document_access_index_backfill', False)), + run_stale_cache_cleanup=payload.get('run_stale_cache_cleanup'), + apply_stale_cache_cleanup=apply_stale_cache_cleanup, + ) + except Exception as exc: + log_event( + '[AppMaintenance] Manual admin maintenance trigger failed.', + extra={'admin_email': admin_email, 'error': str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to run app maintenance.'}), 500 + + if result.get('success'): + log_general_admin_action( + admin_user_id=admin_user_id, + admin_email=admin_email, + action='app_maintenance_manual_run', + description='Manually ran app maintenance foundation tasks.', + additional_context={ + 'run_id': result.get('run_id'), + 'status': result.get('state', {}).get('last_status'), + }, + ) + return jsonify(result), 200 + + log_event( + '[AppMaintenance] Manual admin maintenance run failed.', + extra={ + 'run_id': result.get('run_id'), + 'admin_email': admin_email, + 'error': result.get('error'), + }, + level=logging.ERROR, + ) + return jsonify(result), 500 + @bp.route('/api/admin/settings/check_index_fields', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @@ -521,6 +604,160 @@ def test_connection(): except Exception as e: return jsonify({'error': str(e)}), 500 + @bp.route('/api/admin/settings/redis-monitoring/status', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_redis_monitoring_admin_status(): + """Return sanitized Redis health and capacity metrics for the admin Scale tab.""" + refresh_id = str(uuid.uuid4()) + refresh_start = time.perf_counter() + try: + user = session.get('user', {}) + admin_email = user.get('preferred_username', user.get('email', 'unknown')) + log_event( + '[RedisMonitoring] Admin status refresh requested.', + extra={'refresh_id': refresh_id, 'admin_email': admin_email}, + level=logging.INFO, + ) + status = get_redis_monitoring_status( + get_settings(), + session_redis_client=current_app.config.get('SESSION_REDIS'), + session_type=current_app.config.get('SESSION_TYPE'), + ) + log_event( + '[RedisMonitoring] Admin status refresh completed.', + extra={ + 'refresh_id': refresh_id, + 'status': status.get('health', {}).get('status'), + 'monitoring_source': status.get('runtime', {}).get('monitoring_source'), + 'elapsed_ms': int((time.perf_counter() - refresh_start) * 1000), + }, + level=logging.INFO, + ) + return jsonify(status), 200 + except Exception as e: + log_event( + '[RedisMonitoring] Failed to load admin status.', + extra={ + 'refresh_id': refresh_id, + 'error': str(e), + 'elapsed_ms': int((time.perf_counter() - refresh_start) * 1000), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load Redis monitoring status.'}), 500 + + @bp.route('/api/admin/settings/redis-explorer/keys', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_redis_explorer_admin_keys(): + """Return a cursor-paginated Redis key page for the admin Redis Explorer.""" + refresh_id = str(uuid.uuid4()) + refresh_start = time.perf_counter() + try: + user = session.get('user', {}) + admin_email = user.get('preferred_username', user.get('email', 'unknown')) + log_event( + '[RedisExplorer] Admin key page requested.', + extra={'refresh_id': refresh_id, 'admin_email': admin_email}, + level=logging.INFO, + ) + result = get_redis_explorer_keys( + get_settings(), + cursor=request.args.get('cursor', 0), + page_size=request.args.get('page_size', 25), + key_filter=request.args.get('filter', ''), + session_redis_client=current_app.config.get('SESSION_REDIS'), + session_type=current_app.config.get('SESSION_TYPE'), + ) + log_event( + '[RedisExplorer] Admin key page completed.', + extra={ + 'refresh_id': refresh_id, + 'success': result.get('success'), + 'status': result.get('status'), + 'key_count': len(result.get('keys') or []), + 'has_more': result.get('has_more'), + 'elapsed_ms': int((time.perf_counter() - refresh_start) * 1000), + }, + level=logging.INFO, + ) + return jsonify(result), 200 if result.get('success') else 503 + except Exception as e: + log_event( + '[RedisExplorer] Failed to load key page.', + extra={ + 'refresh_id': refresh_id, + 'error': str(e), + 'elapsed_ms': int((time.perf_counter() - refresh_start) * 1000), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load Redis Explorer keys.'}), 500 + + @bp.route('/api/admin/settings/redis-explorer/value', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_redis_explorer_admin_value(): + """Return sanitized Redis key metadata and preview content for admins.""" + refresh_id = str(uuid.uuid4()) + refresh_start = time.perf_counter() + try: + payload = request.get_json(silent=True) or {} + user = session.get('user', {}) + admin_email = user.get('preferred_username', user.get('email', 'unknown')) + log_event( + '[RedisExplorer] Admin key preview requested.', + extra={'refresh_id': refresh_id, 'admin_email': admin_email}, + level=logging.INFO, + ) + result = get_redis_explorer_value( + get_settings(), + key=payload.get('key'), + session_redis_client=current_app.config.get('SESSION_REDIS'), + session_type=current_app.config.get('SESSION_TYPE'), + ) + status_code = 200 + if not result.get('success'): + status_code = 404 if result.get('status') == 'not_found' else 503 + log_event( + '[RedisExplorer] Admin key preview completed.', + extra={ + 'refresh_id': refresh_id, + 'success': result.get('success'), + 'status': result.get('status'), + 'type': result.get('type'), + 'preview_restricted': result.get('preview_restricted'), + 'elapsed_ms': int((time.perf_counter() - refresh_start) * 1000), + }, + level=logging.INFO, + ) + return jsonify(result), status_code + except ValueError as e: + log_event( + '[RedisExplorer] Invalid key preview request.', + extra={'refresh_id': refresh_id, 'error': str(e)}, + level=logging.WARNING, + ) + return jsonify({'error': str(e)}), 400 + except Exception as e: + log_event( + '[RedisExplorer] Failed to load key preview.', + extra={ + 'refresh_id': refresh_id, + 'error': str(e), + 'elapsed_ms': int((time.perf_counter() - refresh_start) * 1000), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load Redis Explorer key preview.'}), 500 + @bp.route('/api/admin/settings/cosmos-throughput/status', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @@ -538,7 +775,6 @@ def get_cosmos_throughput_admin_status(): level=logging.INFO, ) status = get_cosmos_throughput_status(get_settings(), include_metrics=True, refresh_id=refresh_id) - update_settings(build_runtime_update(status=status)) log_event( '[CosmosThroughput] Admin status refresh completed.', extra={ diff --git a/application/single_app/route_custom_pages.py b/application/single_app/route_custom_pages.py index c68fef49..f20aad92 100644 --- a/application/single_app/route_custom_pages.py +++ b/application/single_app/route_custom_pages.py @@ -203,7 +203,7 @@ def admin_create_custom_page(): if errors: return jsonify({"error": "; ".join(errors)}), 400 slug = str(payload.get("slug") or payload.get("id") or "").strip().lower() - if get_custom_page(slug, include_python=True): + if get_custom_page(slug, include_python=True, use_cache=False): return jsonify({"error": "A custom page with this slug already exists."}), 409 saved = save_custom_page(payload, user_id=_current_admin_user_id()) return jsonify(saved), 201 diff --git a/application/single_app/route_external_public_documents.py b/application/single_app/route_external_public_documents.py index e525099a..66596d7a 100644 --- a/application/single_app/route_external_public_documents.py +++ b/application/single_app/route_external_public_documents.py @@ -7,6 +7,13 @@ from functions_settings import * from functions_public_workspaces import * from functions_documents import * +from functions_document_access_index import ( + DOCUMENT_ACCESS_SCOPE_PUBLIC, + is_document_access_shadow_validation_enabled, + query_items_with_cosmos_diagnostics, + query_document_access_index_documents, + query_document_access_index_legacy_count, +) from functions_appinsights import log_event from functions_activity_logging import log_document_metadata_update_transaction from swagger_wrapper import swagger_route, get_auth_security @@ -236,6 +243,16 @@ def external_get_public_documents(): param_count += 1 where_clause = " AND ".join(query_conditions) + shadow_filters = { + 'search': search_term, + 'classification': classification_filter, + 'classification_none_matches_literal': False, + 'author': author_filter, + 'keywords': keywords_filter, + 'abstract': abstract_filter, + 'array_match_mode': 'exact', + } + used_document_access_index = False # --- 3) Query matching documents, then collapse to current revisions before paginating --- try: @@ -245,12 +262,59 @@ def external_get_public_documents(): FROM c WHERE {where_clause} """ - matching_docs = list(cosmos_public_documents_container.query_items( - query=data_query_str, - parameters=query_params, - enable_cross_partition_query=True - )) - current_docs = sort_documents(select_current_documents(matching_docs)) + index_read_result = query_document_access_index_documents( + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + public_workspace_id=active_workspace_id, + filters=shadow_filters, + ) + if index_read_result.get('success'): + used_document_access_index = True + current_docs = sort_documents(index_read_result.get('documents', [])) + if is_document_access_shadow_validation_enabled(): + try: + matching_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_public_documents_container, + diagnostics_label='source_documents', + query=data_query_str, + parameters=query_params, + enable_cross_partition_query=True + ) + source_current_docs = sort_documents(select_current_documents(matching_docs)) + validate_document_access_index_shadow( + source_current_docs, + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + user_id=user_id, + public_workspace_id=active_workspace_id, + filters=shadow_filters, + source_query_metrics=source_query_metrics, + context='external_get_public_documents', + ) + except Exception as shadow_error: + log_event( + '[DocumentAccessIndex] Shadow validation source query failed after DAI read succeeded.', + extra={'source_scope': DOCUMENT_ACCESS_SCOPE_PUBLIC, 'error': str(shadow_error)}, + level=logging.WARNING, + exceptionTraceback=True, + ) + else: + matching_docs, source_query_metrics = query_items_with_cosmos_diagnostics( + cosmos_public_documents_container, + diagnostics_label='source_documents', + collect_diagnostics=is_document_access_shadow_validation_enabled(), + query=data_query_str, + parameters=query_params, + enable_cross_partition_query=True + ) + current_docs = sort_documents(select_current_documents(matching_docs)) + validate_document_access_index_shadow( + current_docs, + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + user_id=user_id, + public_workspace_id=active_workspace_id, + filters=shadow_filters, + source_query_metrics=source_query_metrics, + context='external_get_public_documents', + ) total_count = len(current_docs) docs = current_docs[offset:offset + page_size] except Exception as e: @@ -263,27 +327,34 @@ def external_get_public_documents(): # --- new: do we have any legacy documents? --- - try: - legacy_q = """ - SELECT VALUE COUNT(1) - FROM c - WHERE c.public_workspace_id = @public_workspace_id - AND NOT IS_DEFINED(c.percentage_complete) - """ - legacy_docs = list( - cosmos_public_documents_container.query_items( - query=legacy_q, - parameters=[{"name":"@public_workspace_id","value":active_workspace_id}], - enable_cross_partition_query=True - ) - ) - legacy_count = legacy_docs[0] if legacy_docs else 0 - except Exception as e: - log_event( - '[ExternalPublicDocuments] Error executing public legacy document query.', - extra={'public_workspace_id': active_workspace_id, 'error': str(e)}, - level=logging.ERROR, + if used_document_access_index: + legacy_count_result = query_document_access_index_legacy_count( + source_scope=DOCUMENT_ACCESS_SCOPE_PUBLIC, + public_workspace_id=active_workspace_id, ) + legacy_count = legacy_count_result.get('legacy_count', 0) if legacy_count_result.get('success') else 0 + else: + try: + legacy_q = """ + SELECT VALUE COUNT(1) + FROM c + WHERE c.public_workspace_id = @public_workspace_id + AND NOT IS_DEFINED(c.percentage_complete) + """ + legacy_docs = list( + cosmos_public_documents_container.query_items( + query=legacy_q, + parameters=[{"name":"@public_workspace_id","value":active_workspace_id}], + enable_cross_partition_query=True + ) + ) + legacy_count = legacy_docs[0] if legacy_docs else 0 + except Exception as e: + log_event( + '[ExternalPublicDocuments] Error executing public legacy document query.', + extra={'public_workspace_id': active_workspace_id, 'error': str(e)}, + level=logging.ERROR, + ) # --- 5) Return results --- return jsonify({ diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index d324a552..751b0c1f 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -1671,6 +1671,63 @@ def is_valid_url(url): cosmos_throughput_settings = normalize_cosmos_throughput_settings(cosmos_throughput_candidate_settings) + document_access_index_backfill_batch_size = min( + 1000, + max( + 1, + parse_admin_int( + form_data.get('document_access_index_backfill_batch_size'), + settings.get('document_access_index_backfill_batch_size', 200), + 'document_access_index_backfill_batch_size', + 200, + ), + ), + ) + document_access_index_repair_batch_size = min( + 500, + max( + 1, + parse_admin_int( + form_data.get('document_access_index_repair_batch_size'), + settings.get('document_access_index_repair_batch_size', 100), + 'document_access_index_repair_batch_size', + 100, + ), + ), + ) + document_access_index_cache_ttl_seconds = min( + 900, + max( + 60, + parse_admin_int( + form_data.get('document_access_index_cache_ttl_seconds'), + settings.get('document_access_index_cache_ttl_seconds', 900), + 'document_access_index_cache_ttl_seconds', + 900, + ), + ), + ) + conversation_cache_ttl_seconds = max( + 0, + parse_admin_int( + form_data.get('conversation_cache_ttl_seconds'), + settings.get('conversation_cache_ttl_seconds', 120), + 'conversation_cache_ttl_seconds', + 120, + ), + ) + dai_debug_enabled = bool(settings.get('enable_dai_debug', False)) + document_access_index_shadow_validation_enabled = ( + form_data.get('enable_document_access_index_shadow_validation') == 'on' + if dai_debug_enabled + else bool(settings.get('enable_document_access_index_shadow_validation', False)) + ) + document_access_index_cache_enabled = ( + form_data.get('enable_document_access_index_cache') == 'on' + if dai_debug_enabled + else bool(settings.get('enable_document_access_index_cache', True)) + ) + # --- Chunk Size Overrides --- chunk_size_defaults = get_chunk_size_defaults() existing_chunk_sizes = settings.get('chunk_size', {}) if isinstance(settings, dict) else {} @@ -1829,6 +1886,24 @@ def is_valid_url(url): 'redis_url': form_data.get('redis_url', '').strip(), 'redis_key': admin_secret('redis_key'), 'redis_auth_type': form_data.get('redis_auth_type', '').strip(), + 'enable_conversation_cache': form_data.get('enable_conversation_cache') == 'on', + 'conversation_cache_ttl_seconds': conversation_cache_ttl_seconds, + + # Document Access Index + 'enable_document_access_index_container': True, + 'enable_document_access_index_write_through': True, + 'enable_document_access_index_reads': True, + 'enable_dai_debug': dai_debug_enabled, + 'enable_document_access_index_shadow_validation': document_access_index_shadow_validation_enabled, + 'enable_startup_document_access_index_backfill': True, + 'document_access_index_backfill_batch_size': document_access_index_backfill_batch_size, + 'document_access_index_repair_batch_size': document_access_index_repair_batch_size, + 'document_access_index_active_maintenance_interval_seconds': settings.get( + 'document_access_index_active_maintenance_interval_seconds', + 30, + ), + 'enable_document_access_index_cache': document_access_index_cache_enabled, + 'document_access_index_cache_ttl_seconds': document_access_index_cache_ttl_seconds, # Workspaces 'enable_user_workspace': form_data.get('enable_user_workspace') == 'on', diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 55401dfb..5af7d93a 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -35,6 +35,11 @@ from functions_public_workspaces import find_public_workspace_by_id, get_user_visible_public_workspace_ids_from_settings from functions_simplechat_operations import upload_chat_image_bytes_for_user from functions_appinsights import log_event +from functions_chat_bootstrap_cache import ( + build_chat_bootstrap_cache_key, + get_cached_chat_bootstrap_payload, + set_cached_chat_bootstrap_payload, +) from swagger_wrapper import swagger_route, get_auth_security from functions_debug import debug_print from utils_cache import invalidate_group_search_cache, invalidate_personal_search_cache @@ -647,6 +652,16 @@ def _build_chat_prompt_catalog(*, user_id, settings, user_groups_raw, user_visib return catalog + +def _is_valid_chat_bootstrap_payload(payload): + return ( + isinstance(payload, dict) + and isinstance(payload.get('chat_agent_options'), list) + and isinstance(payload.get('chat_model_options'), list) + and isinstance(payload.get('chat_prompt_options'), list) + ) + + def register_route_frontend_chats(bp): @bp.route('/chats', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -754,33 +769,77 @@ def chats(): logger.warning(f"Failed to load visible public workspaces for chats page: {e}") chat_agent_options = [] - try: - all_chat_agent_options = build_accessible_agent_catalog( - user_id, - settings=settings, - user_groups=user_groups_raw, - ) - chat_agent_options = [ - agent for agent in all_chat_agent_options - if _is_chat_agent_allowed_by_governance( - user_id, - agent, - str(agent.get('scope_type') or '').strip().lower() or 'personal', - ) - ] - except Exception as e: - logger.warning(f"Failed to load chat agent options: {e}") - chat_model_options = [] + chat_prompt_options = [] + bootstrap_cache_key = None + cached_bootstrap_payload = None try: - chat_model_options = _build_chat_model_catalog( - user_id=user_id, + bootstrap_cache_key = build_chat_bootstrap_cache_key( + user_id, settings=settings, user_settings_dict=user_settings_dict, user_groups_raw=user_groups_raw, + user_visible_public_workspaces=user_visible_public_workspaces, ) + cached_bootstrap_payload = get_cached_chat_bootstrap_payload(bootstrap_cache_key) except Exception as e: - logger.warning(f"Failed to load chat model options: {e}") + logger.warning(f"Failed to load chat bootstrap cache: {e}") + + if _is_valid_chat_bootstrap_payload(cached_bootstrap_payload): + chat_agent_options = cached_bootstrap_payload.get('chat_agent_options') or [] + chat_model_options = cached_bootstrap_payload.get('chat_model_options') or [] + chat_prompt_options = cached_bootstrap_payload.get('chat_prompt_options') or [] + else: + try: + all_chat_agent_options = build_accessible_agent_catalog( + user_id, + settings=settings, + user_groups=user_groups_raw, + ) + chat_agent_options = [ + agent for agent in all_chat_agent_options + if _is_chat_agent_allowed_by_governance( + user_id, + agent, + str(agent.get('scope_type') or '').strip().lower() or 'personal', + ) + ] + except Exception as e: + logger.warning(f"Failed to load chat agent options: {e}") + + try: + chat_model_options = _build_chat_model_catalog( + user_id=user_id, + settings=settings, + user_settings_dict=user_settings_dict, + user_groups_raw=user_groups_raw, + ) + except Exception as e: + logger.warning(f"Failed to load chat model options: {e}") + + try: + chat_prompt_options = _build_chat_prompt_catalog( + user_id=user_id, + settings=settings, + user_groups_raw=user_groups_raw, + user_visible_public_workspaces=user_visible_public_workspaces, + ) + except Exception as e: + logger.warning(f"Failed to load chat prompt options: {e}") + + if bootstrap_cache_key: + try: + set_cached_chat_bootstrap_payload( + bootstrap_cache_key, + { + 'chat_agent_options': chat_agent_options, + 'chat_model_options': chat_model_options, + 'chat_prompt_options': chat_prompt_options, + }, + ttl_seconds=int(settings.get('chat_bootstrap_cache_ttl_seconds') or 300), + ) + except Exception as e: + logger.warning(f"Failed to store chat bootstrap cache: {e}") initial_chat_model_selection = _build_initial_chat_model_selection( chat_model_options=chat_model_options, @@ -788,17 +847,6 @@ def chats(): preferred_model_deployment=user_settings_dict.get('preferredModelDeployment'), ) - chat_prompt_options = [] - try: - chat_prompt_options = _build_chat_prompt_catalog( - user_id=user_id, - settings=settings, - user_groups_raw=user_groups_raw, - user_visible_public_workspaces=user_visible_public_workspaces, - ) - except Exception as e: - logger.warning(f"Failed to load chat prompt options: {e}") - return render_template( 'chats.html', settings=public_settings, diff --git a/application/single_app/semantic_kernel_plugins/cosmos_query_plugin.py b/application/single_app/semantic_kernel_plugins/cosmos_query_plugin.py index d760e28a..d28166b6 100644 --- a/application/single_app/semantic_kernel_plugins/cosmos_query_plugin.py +++ b/application/single_app/semantic_kernel_plugins/cosmos_query_plugin.py @@ -9,7 +9,7 @@ import re from typing import Any, Dict, List, Optional, Sequence, Union -from azure.cosmos import CosmosClient +import azure.cosmos as azure_cosmos from azure.cosmos.exceptions import CosmosHttpResponseError from azure.identity import DefaultAzureCredential from semantic_kernel.functions import kernel_function @@ -34,7 +34,7 @@ def __repr__(self) -> str: class CosmosQueryPlugin(BasePlugin): """Read-only Azure Cosmos DB for NoSQL query plugin.""" - _client_cache: Dict[str, CosmosClient] = {} + _client_cache: Dict[str, azure_cosmos.CosmosClient] = {} def __init__(self, manifest: Dict[str, Any]): super().__init__(manifest) @@ -310,7 +310,7 @@ def _get_container_client(self): client_cache_key = self._get_client_cache_key() client = self._client_cache.get(client_cache_key) if client is None: - client = CosmosClient( + client = azure_cosmos.CosmosClient( self.endpoint, credential=self._get_client_credential(), timeout=self.timeout, diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 1de01978..b5e996ac 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -37,6 +37,16 @@ let currentCosmosContainers = []; let currentCosmosMetricsWindowMinutes = 0; let currentCosmosStatusLoaded = false; let currentCosmosContainerSort = { field: 'container_name', direction: 'asc' }; +let documentAccessIndexStatusPollId = null; +let redisExplorerState = { + cursor: '0', + nextCursor: '0', + cursorHistory: [], + filter: '', + pageSize: 25, + loaded: false, + selectedKey: '' +}; const COSMOS_CONTAINER_SORT_FIELDS = new Set([ 'container_name', @@ -48,6 +58,7 @@ const COSMOS_CONTAINER_SORT_FIELDS = new Set([ const COSMOS_CONTAINER_TEXT_SORT_FIELDS = new Set(['container_name', 'policy']); const COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU = 10000; const COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE = 'Throughput above 10,000 RU/s is monitored only in SimpleChat. Use the Azure portal to change capacity; capacity changes above this level can take 4 to 6 hours.'; +const DOCUMENT_ACCESS_INDEX_STATUS_POLL_INTERVAL_MS = 7500; const GROUP_WORKFLOW_ASSIGNMENT_PARSE_DEPTH_LIMIT = 5; const enableClassificationToggle = document.getElementById('enable_document_classification'); @@ -495,6 +506,106 @@ function formatNumber(value) { return numericValue.toLocaleString(); } +function formatRedisStatus(value) { + const normalizedValue = String(value || '').trim(); + if (!normalizedValue) { + return 'Not loaded'; + } + + return normalizedValue + .replace(/_/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()); +} + +function getRedisMonitoringStatusVariant(value) { + const normalizedValue = String(value || '').trim().toLowerCase(); + if (normalizedValue === 'healthy' || normalizedValue === 'active') { + return 'success'; + } + if (['degraded', 'not_configured', 'unavailable'].includes(normalizedValue)) { + return 'warning'; + } + if (normalizedValue === 'error') { + return 'danger'; + } + return 'secondary'; +} + +function setRedisMonitoringMessage(message, variant = 'info') { + const messageElement = document.getElementById('redis-monitoring-message'); + if (!messageElement) { + return; + } + + messageElement.textContent = message || ''; + messageElement.className = `alert alert-${variant} small mb-3`; + messageElement.classList.toggle('d-none', !message); +} + +function setRedisMonitoringBadge(elementId, value, variant = 'secondary') { + const badge = document.getElementById(elementId); + if (!badge) { + return; + } + + const safeVariants = new Set(['primary', 'secondary', 'success', 'danger', 'warning', 'info']); + badge.textContent = value || 'Not loaded'; + badge.className = `badge text-bg-${safeVariants.has(variant) ? variant : 'secondary'}`; +} + +function formatRedisMetric(value, unit) { + if (value === null || value === undefined || value === '') { + return 'Not available'; + } + const numericValue = Number(value); + if (Number.isNaN(numericValue)) { + return 'Not available'; + } + return `${numericValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} ${unit}`; +} + +function formatRedisPercent(value) { + if (value === null || value === undefined || value === '') { + return 'Not available'; + } + const numericValue = Number(value); + if (Number.isNaN(numericValue)) { + return 'Not available'; + } + return `${numericValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`; +} + +function formatRedisMemoryUsage(memory) { + const usedMemory = memory?.used_memory_human || formatRedisMetric(memory?.used_memory, 'bytes'); + const maxMemory = memory?.maxmemory_human || formatRedisMetric(memory?.maxmemory, 'bytes'); + const usagePercent = formatRedisPercent(memory?.usage_percent); + + if (usedMemory === 'Not available') { + return 'Not available'; + } + if (maxMemory === 'Not available' || Number(memory?.maxmemory || 0) === 0) { + return `${usedMemory} / no maxmemory limit`; + } + if (usagePercent === 'Not available') { + return `${usedMemory} / ${maxMemory}`; + } + return `${usedMemory} / ${maxMemory} (${usagePercent})`; +} + +function formatRedisRuntime(value) { + return value ? 'Active' : 'Inactive'; +} + +function setRedisTestResult(resultDiv, message, variant = 'muted') { + if (!resultDiv) { + return; + } + + const safeVariants = new Set(['success', 'danger', 'muted', 'info']); + resultDiv.textContent = message || ''; + resultDiv.className = `mt-2 text-${safeVariants.has(variant) ? variant : 'muted'}`; +} + function formatRu(value) { const formattedValue = formatNumber(value); return formattedValue === 'Not available' ? formattedValue : `${formattedValue} RU/s`; @@ -1341,100 +1452,1269 @@ function updateCosmosThroughputStatusPanel(status) { renderCosmosContainerMetrics(currentCosmosContainers); renderCosmosContainerPolicyModal(currentCosmosContainers); - const databasePortalManaged = isCosmosThroughputPortalManaged(throughput); - const globalScaleButtonsDisabled = status?.capacity_scope === 'container' || !throughput.is_scalable || databasePortalManaged; - const globalScaleUpButtonDisabled = globalScaleButtonsDisabled || isCosmosScaleUpBlockedBySimpleChatLimit(throughput); - const globalConvertButtonDisabled = status?.capacity_scope === 'container' || throughput.mode !== 'manual' || !throughput.is_scalable || databasePortalManaged; - document.getElementById('cosmos-throughput-convert-autoscale-btn')?.toggleAttribute('disabled', globalConvertButtonDisabled); - document.getElementById('cosmos-throughput-scale-up-btn')?.toggleAttribute('disabled', globalScaleUpButtonDisabled); - document.getElementById('cosmos-throughput-scale-down-btn')?.toggleAttribute('disabled', globalScaleButtonsDisabled); + const databasePortalManaged = isCosmosThroughputPortalManaged(throughput); + const globalScaleButtonsDisabled = status?.capacity_scope === 'container' || !throughput.is_scalable || databasePortalManaged; + const globalScaleUpButtonDisabled = globalScaleButtonsDisabled || isCosmosScaleUpBlockedBySimpleChatLimit(throughput); + const globalConvertButtonDisabled = status?.capacity_scope === 'container' || throughput.mode !== 'manual' || !throughput.is_scalable || databasePortalManaged; + document.getElementById('cosmos-throughput-convert-autoscale-btn')?.toggleAttribute('disabled', globalConvertButtonDisabled); + document.getElementById('cosmos-throughput-scale-up-btn')?.toggleAttribute('disabled', globalScaleUpButtonDisabled); + document.getElementById('cosmos-throughput-scale-down-btn')?.toggleAttribute('disabled', globalScaleButtonsDisabled); +} + +function hasContainerLevelCosmosMetrics(status) { + return (status?.containers || []).some(container => ( + container?.normalized_ru_percent !== null + && container?.normalized_ru_percent !== undefined + ) || ( + container?.request_units !== null + && container?.request_units !== undefined + )); +} + +function hasPortalManagedCosmosThroughput(status) { + return Boolean(status?.throughput?.portal_managed_scaling_required) || (status?.containers || []).some(container => ( + isCosmosThroughputPortalManaged(container) + )); +} + +function getCachedCosmosThroughputStatus() { + const cachedStatus = window.cosmosThroughputCachedStatus; + if (!cachedStatus || typeof cachedStatus !== 'object' || Array.isArray(cachedStatus)) { + return null; + } + + const hasStatusData = Boolean( + cachedStatus.last_checked_at + || cachedStatus.capacity_scope + || (Array.isArray(cachedStatus.containers) && cachedStatus.containers.length > 0) + ); + return hasStatusData ? cachedStatus : null; +} + +function initializeCosmosThroughputStatusView() { + const cachedStatus = getCachedCosmosThroughputStatus(); + const automationEnabled = isFieldChecked('cosmos_throughput_autoscale_enabled'); + + if (cachedStatus) { + updateCosmosThroughputStatusPanel(cachedStatus); + const refreshText = automationEnabled + ? 'Background automation refreshes this saved view on the Metrics Window cadence while enabled.' + : 'Automation is currently disabled; use Refresh to update this saved view.'; + setCosmosThroughputMessage(`Showing last saved Cosmos throughput status. ${refreshText}`, 'info'); + return; + } + + if (automationEnabled) { + setCosmosThroughputMessage('No saved Cosmos throughput status is available yet. Loading the first status check now...', 'info'); + loadCosmosThroughputStatus(); + } +} + +async function loadCosmosThroughputStatus(event = null) { + const triggerButton = event?.currentTarget || document.getElementById('cosmos-throughput-refresh-btn'); + if (triggerButton) { + setButtonBusy(triggerButton, true, 'Loading...'); + } + setCosmosThroughputMessage('Loading Cosmos throughput status...', 'info'); + + try { + const response = await fetch('/api/admin/settings/cosmos-throughput/status', { + method: 'GET', + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to load Cosmos throughput status.'); + } + + updateCosmosThroughputStatusPanel(data); + if (!data.configured) { + setCosmosThroughputMessage(data.error || 'Cosmos throughput management needs subscription, resource group, account, and database settings.', 'warning'); + } else if (data.throughput_error) { + setCosmosThroughputMessage(`Cosmos database throughput could not be read. ${data.throughput_error}`, 'danger'); + } else if (data.capacity_scope === 'container' && data.metrics?.normalized_ru_percent !== null && data.metrics?.normalized_ru_percent !== undefined && !hasContainerLevelCosmosMetrics(data)) { + setCosmosThroughputMessage('Azure Monitor returned aggregate RU utilization, but not per-container metric dimensions for this window. Container autoscale waits for per-container utilization before scaling individual containers; Refresh again after a few minutes.', 'warning'); + } else if (hasPortalManagedCosmosThroughput(data)) { + setCosmosThroughputMessage('One or more Cosmos throughput targets are above 10,000 RU/s. SimpleChat will monitor utilization and request units only; use the Azure portal for capacity changes, which can take 4 to 6 hours.', 'warning'); + } else if (data.capacity_scope === 'container') { + setCosmosThroughputMessage('Container-targeted throughput is active. Dedicated-throughput containers can be monitored and scaled individually; containers sharing database throughput remain view-only.', 'info'); + } else if (data.metric_error) { + setCosmosThroughputMessage('Throughput loaded, but Azure Monitor metrics are unavailable. Check the app identity permissions and Cosmos metrics availability.', 'warning'); + } else { + setCosmosThroughputMessage('Cosmos throughput status loaded.', 'success'); + } + } catch (error) { + setCosmosThroughputMessage(error.message || 'Failed to load Cosmos throughput status.', 'danger'); + } finally { + if (triggerButton) { + setButtonBusy(triggerButton, false); + } + } +} + +function buildCosmosThroughputAccessPayload() { + return { + cosmos_throughput_autoscale_enabled: isFieldChecked('cosmos_throughput_autoscale_enabled'), + cosmos_throughput_auto_scale_up_enabled: isFieldChecked('cosmos_throughput_auto_scale_up_enabled'), + cosmos_throughput_auto_scale_down_enabled: isFieldChecked('cosmos_throughput_auto_scale_down_enabled'), + cosmos_throughput_subscription_id: getFieldValue('cosmos_throughput_subscription_id'), + cosmos_throughput_resource_group: getFieldValue('cosmos_throughput_resource_group'), + cosmos_throughput_account_name: getFieldValue('cosmos_throughput_account_name'), + cosmos_throughput_database_name: getFieldValue('cosmos_throughput_database_name'), + cosmos_throughput_metrics_window_minutes: getNumericFieldValue('cosmos_throughput_metrics_window_minutes', 5), + cosmos_throughput_scale_up_threshold_percent: getNumericFieldValue('cosmos_throughput_scale_up_threshold_percent', 90), + cosmos_throughput_scale_down_threshold_percent: getNumericFieldValue('cosmos_throughput_scale_down_threshold_percent', 70), + cosmos_throughput_scale_up_step_ru: getNumericFieldValue('cosmos_throughput_scale_up_step_ru', 1000), + cosmos_throughput_scale_down_step_ru: getNumericFieldValue('cosmos_throughput_scale_down_step_ru', 1000), + cosmos_throughput_scale_up_cooldown_minutes: getNumericFieldValue('cosmos_throughput_scale_up_cooldown_minutes', 5), + cosmos_throughput_scale_down_cooldown_minutes: getNumericFieldValue('cosmos_throughput_scale_down_cooldown_minutes', 20), + cosmos_throughput_min_ru: getNumericFieldValue('cosmos_throughput_min_ru', 1000), + cosmos_throughput_max_ru: getNumericFieldValue('cosmos_throughput_max_ru', COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU), + cosmos_throughput_ignore_min_limit: isFieldChecked('cosmos_throughput_ignore_min_limit'), + cosmos_throughput_ignore_max_limit: isFieldChecked('cosmos_throughput_ignore_max_limit'), + cosmos_throughput_convert_manual_to_autoscale_enabled: isFieldChecked('cosmos_throughput_convert_manual_to_autoscale_enabled'), + cosmos_throughput_enforce_container_defaults: isFieldChecked('cosmos_throughput_enforce_container_defaults'), + cosmos_throughput_container_policies: collectCosmosContainerPolicies() + }; +} + +async function validateCosmosThroughputAccess(triggerButton = null) { + const button = triggerButton || document.getElementById('cosmos-throughput-validate-access-btn'); + if (button) { + setButtonBusy(button, true, 'Validating...'); + } + setCosmosThroughputMessage('Validating Cosmos throughput configuration and access...', 'info'); + + try { + const response = await fetch('/api/admin/settings/cosmos-throughput/validate-access', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify(buildCosmosThroughputAccessPayload()) + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to validate Cosmos throughput access.'); + } + + if (data.status?.configured) { + updateCosmosThroughputStatusPanel(data.status); + } + setCosmosThroughputValidationResult(data); + } catch (error) { + setCosmosThroughputMessage(error.message || 'Failed to validate Cosmos throughput access.', 'danger'); + } finally { + if (button) { + setButtonBusy(button, false); + } + } +} + +async function manuallyScaleCosmosThroughput(direction, containerName = '', triggerButton = null) { + const button = triggerButton || document.getElementById(`cosmos-throughput-scale-${direction}-btn`); + if (button) { + setButtonBusy(button, true, direction === 'up' ? 'Scaling up...' : 'Scaling down...'); + } + const targetText = containerName ? ` for ${containerName}` : ''; + setCosmosThroughputMessage(direction === 'up' ? `Submitting scale-up request${targetText}...` : `Submitting scale-down request${targetText}...`, 'info'); + + try { + const response = await fetch('/api/admin/settings/cosmos-throughput/scale', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ direction, container_name: containerName }) + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Cosmos throughput scale request failed.'); + } + + const scaledTarget = data.container_name ? ` for ${data.container_name}` : ''; + setCosmosThroughputMessage(`Cosmos throughput${scaledTarget} changed from ${formatRu(data.from_ru)} to ${formatRu(data.to_ru)}.`, 'success'); + await loadCosmosThroughputStatus(); + } catch (error) { + setCosmosThroughputMessage(error.message || 'Cosmos throughput scale request failed.', 'danger'); + } finally { + if (button) { + setButtonBusy(button, false, direction === 'up' ? 'Scale Up' : 'Scale Down'); + } + } +} + +async function convertCosmosThroughputToAutoscale(containerName = '', triggerButton = null) { + const targetText = containerName ? ` for ${containerName}` : ''; + if (triggerButton) { + setButtonBusy(triggerButton, true, 'Converting...'); + } + setCosmosThroughputMessage(`Converting manual Cosmos throughput${targetText} to native autoscale...`, 'info'); + + try { + const response = await fetch('/api/admin/settings/cosmos-throughput/convert-autoscale', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ container_name: containerName }) + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Cosmos throughput mode conversion failed.'); + } + + const convertedTarget = data.container_name ? ` for ${data.container_name}` : ''; + setCosmosThroughputMessage(`Cosmos throughput${convertedTarget} converted from manual ${formatRu(data.from_ru)} to autoscale max ${formatRu(data.to_ru)}.`, 'success'); + await loadCosmosThroughputStatus(); + } catch (error) { + setCosmosThroughputMessage(error.message || 'Cosmos throughput mode conversion failed.', 'danger'); + } finally { + if (triggerButton) { + setButtonBusy(triggerButton, false); + } + } +} + +function renderRedisMonitoringStatus(statusPayload) { + const configuration = statusPayload?.configuration || {}; + const runtime = statusPayload?.runtime || {}; + const health = statusPayload?.health || {}; + const memory = statusPayload?.memory || {}; + const clients = statusPayload?.clients || {}; + const stats = statusPayload?.stats || {}; + const keyspace = statusPayload?.keyspace || {}; + const server = statusPayload?.server || {}; + const daiCache = statusPayload?.dai_cache || {}; + + let configurationText = 'Disabled'; + let configurationVariant = 'secondary'; + if (configuration.enabled && configuration.configured) { + configurationText = 'Enabled'; + configurationVariant = 'success'; + } else if (configuration.enabled) { + configurationText = 'Needs host'; + configurationVariant = 'warning'; + } + + setRedisMonitoringBadge('redis-monitoring-config-status', configurationText, configurationVariant); + setRedisMonitoringBadge( + 'redis-monitoring-health-status', + formatRedisStatus(health.status), + getRedisMonitoringStatusVariant(health.status) + ); + setRedisMonitoringBadge( + 'redis-monitoring-app-cache-status', + formatRedisRuntime(runtime.app_cache_using_redis), + runtime.app_cache_using_redis ? 'success' : 'secondary' + ); + setRedisMonitoringBadge( + 'redis-monitoring-session-status', + formatRedisRuntime(runtime.session_using_redis), + runtime.session_using_redis ? 'success' : 'secondary' + ); + + setElementText('redis-monitoring-ping-latency', formatRedisMetric(health.ping_latency_ms, 'ms')); + setElementText('redis-monitoring-memory-usage', formatRedisMemoryUsage(memory)); + setElementText( + 'redis-monitoring-memory-policy', + memory.maxmemory_policy ? `Policy: ${memory.maxmemory_policy}` : 'Policy: Not available' + ); + setElementText('redis-monitoring-connected-clients', formatNumber(clients.connected_clients)); + setElementText('redis-monitoring-ops-per-sec', formatNumber(stats.instantaneous_ops_per_sec)); + setElementText('redis-monitoring-hit-rate', formatRedisPercent(stats.keyspace_hit_rate_percent)); + setElementText('redis-monitoring-key-count', formatNumber(keyspace.total_keys)); + setElementText('redis-monitoring-dai-version-markers', formatNumber(daiCache.version_marker_count)); + setElementText( + 'redis-monitoring-dai-version-marker-expiry', + `${formatNumber(daiCache.version_marker_no_expiry_count)} no-expiry marker(s)` + ); + setElementText('redis-monitoring-dai-payload-keys', formatNumber(daiCache.payload_key_count)); + setElementText( + 'redis-monitoring-dai-version-ttl-policy', + `Version TTL: ${formatRedisExplorerTtl(daiCache.version_marker_ttl_seconds)}` + ); + setElementText('redis-monitoring-expired-keys', formatNumber(stats.expired_keys)); + setElementText('redis-monitoring-evicted-keys', formatNumber(stats.evicted_keys)); + setElementText('redis-monitoring-fragmentation', formatNumber(memory.mem_fragmentation_ratio)); + setElementText('redis-monitoring-errors', formatNumber(stats.total_error_replies)); + setElementText('redis-monitoring-rejected-connections', formatNumber(stats.rejected_connections)); + setElementText('redis-monitoring-version', server.redis_version || 'Not available'); + setElementText('redis-monitoring-source', formatRedisStatus(runtime.monitoring_source)); + setElementText('redis-monitoring-checked-at', statusPayload?.checked_at || 'Not available'); + setElementText('redis-monitoring-last-error', health.last_error || 'None'); +} + +async function loadRedisMonitoringStatus(event = null, options = {}) { + const showLoading = options.showLoading !== false; + const triggerButton = event?.currentTarget || (showLoading ? document.getElementById('redis-monitoring-refresh-btn') : null); + if (triggerButton) { + setButtonBusy(triggerButton, true, 'Loading...'); + } + if (showLoading) { + setRedisMonitoringMessage('Loading Redis monitoring status...', 'info'); + } + + try { + const response = await fetch('/api/admin/settings/redis-monitoring/status', { + method: 'GET', + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to load Redis monitoring status.'); + } + + renderRedisMonitoringStatus(data); + if (data.health?.status === 'healthy') { + setRedisMonitoringMessage('Redis monitoring status loaded.', 'success'); + } else if (data.health?.last_error) { + setRedisMonitoringMessage(data.health.last_error, data.health.status === 'error' ? 'danger' : 'warning'); + } else if (showLoading) { + setRedisMonitoringMessage('Redis monitoring status loaded.', 'info'); + } + } catch (error) { + setRedisMonitoringMessage(error.message || 'Failed to load Redis monitoring status.', 'danger'); + } finally { + if (triggerButton) { + setButtonBusy(triggerButton, false); + } + } +} + +function setRedisExplorerMessage(message, variant = 'info') { + const messageElement = document.getElementById('redis-explorer-message'); + if (!messageElement) { + return; + } + + messageElement.textContent = message || ''; + messageElement.className = `alert alert-${variant} small`; + messageElement.classList.toggle('d-none', !message); +} + +function formatRedisExplorerTtl(ttlSeconds) { + const numericValue = Number(ttlSeconds); + if (Number.isNaN(numericValue)) { + return 'Not available'; + } + if (numericValue === -2) { + return 'Expired or missing'; + } + if (numericValue === -1) { + return 'No expiry'; + } + return `${numericValue.toLocaleString()} sec`; +} + +function formatRedisExplorerMemory(bytes) { + const numericValue = Number(bytes); + if (Number.isNaN(numericValue) || numericValue < 0) { + return 'Not available'; + } + if (numericValue >= 1024 * 1024) { + return `${(numericValue / (1024 * 1024)).toLocaleString(undefined, { maximumFractionDigits: 2 })} MB`; + } + if (numericValue >= 1024) { + return `${(numericValue / 1024).toLocaleString(undefined, { maximumFractionDigits: 2 })} KB`; + } + return `${numericValue.toLocaleString()} bytes`; +} + + +function formatRedisExplorerResolutionLabel(resolution) { + if (!resolution) { + return ''; + } + if (resolution.resolved && resolution.entity_type) { + const entityType = formatRedisStatus(resolution.entity_type); + const entityName = resolution.entity_name || resolution.entity_id || 'Unknown'; + return `${resolution.label || 'SimpleChat entity'}: ${entityType} - ${entityName}`; + } + return resolution.label || ''; +} + + +function formatRedisExplorerResolutionEntity(resolution) { + if (!resolution) { + return 'Not resolved'; + } + if (!resolution.resolved) { + return formatRedisStatus(resolution.resolution_status || 'unresolved'); + } + const parts = []; + if (resolution.entity_type) { + parts.push(`Entity: ${formatRedisStatus(resolution.entity_type)}`); + } + if (resolution.entity_name) { + parts.push(`Name: ${resolution.entity_name}`); + } + if (resolution.entity_status) { + parts.push(`Status: ${formatRedisStatus(resolution.entity_status)}`); + } + if (Number.isFinite(Number(resolution.row_count))) { + parts.push(`DAI rows: ${formatNumber(resolution.row_count)}`); + } + return parts.join(' | ') || 'Resolved'; +} + + +function renderRedisExplorerResolution(resolution) { + const resolutionCard = document.getElementById('redis-explorer-resolution-card'); + if (!resolutionCard) { + return; + } + + resolutionCard.classList.toggle('d-none', !resolution); + if (!resolution) { + return; + } + + setElementText('redis-explorer-resolution-kind', resolution.label || formatRedisStatus(resolution.kind)); + setElementText('redis-explorer-resolution-entity', formatRedisExplorerResolutionEntity(resolution)); + setElementText('redis-explorer-resolution-scope', resolution.scope_key || resolution.cache_hash || resolution.scope_hash || 'No reversible scope key available'); + setElementText('redis-explorer-resolution-note', resolution.note || 'Resolved from Redis key classification.'); +} + + +function setRedisExplorerPreviewVisible(isVisible) { + document.getElementById('redis-explorer-preview-empty')?.classList.toggle('d-none', isVisible); + document.getElementById('redis-explorer-preview-panel')?.classList.toggle('d-none', !isVisible); +} + +function resetRedisExplorerPreview() { + redisExplorerState.selectedKey = ''; + setRedisExplorerPreviewVisible(false); + setElementText('redis-explorer-preview-key', 'Not loaded'); + setElementText('redis-explorer-preview-type', 'Not loaded'); + setElementText('redis-explorer-preview-ttl', 'Not loaded'); + setElementText('redis-explorer-preview-memory', 'Not loaded'); + setElementText('redis-explorer-preview-redacted', 'Not loaded'); + setElementText('redis-explorer-preview-content', 'Not loaded'); + renderRedisExplorerResolution(null); +} + +function getRedisExplorerScopeLabel() { + const filter = (redisExplorerState.filter || '').trim(); + return filter ? `Filter: "${filter}"` : 'Browsing all keys'; +} + +function renderRedisExplorerKeys(payload) { + const keyList = document.getElementById('redis-explorer-key-list'); + if (!keyList) { + return; + } + + const keys = Array.isArray(payload?.keys) ? payload.keys : []; + keyList.replaceChildren(); + const pageNumber = redisExplorerState.cursorHistory.length + 1; + setElementText( + 'redis-explorer-key-count', + `${getRedisExplorerScopeLabel()} | Page ${formatNumber(pageNumber)} | ${formatNumber(keys.length)} key(s)${payload?.has_more ? ' / more available' : ''}` + ); + + if (keys.length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'list-group-item text-muted small'; + emptyState.textContent = 'No Redis keys matched this page and filter.'; + keyList.appendChild(emptyState); + } else { + keys.forEach(item => { + const keyButton = document.createElement('button'); + keyButton.type = 'button'; + keyButton.className = 'list-group-item list-group-item-action'; + keyButton.setAttribute('role', 'option'); + + const keyName = document.createElement('div'); + keyName.className = 'fw-semibold text-break'; + keyName.textContent = item.key || '(empty key)'; + + const metadata = document.createElement('div'); + metadata.className = 'small text-muted'; + metadata.textContent = [ + `Type: ${formatRedisStatus(item.type || 'unknown')}`, + `TTL: ${formatRedisExplorerTtl(item.ttl_seconds)}`, + item.preview_restricted ? 'Preview: restricted' : 'Preview: sanitized' + ].join(' | '); + + keyButton.appendChild(keyName); + keyButton.appendChild(metadata); + const resolutionLabel = formatRedisExplorerResolutionLabel(item.resolution); + if (resolutionLabel) { + const resolutionMetadata = document.createElement('div'); + resolutionMetadata.className = 'small text-primary'; + resolutionMetadata.textContent = resolutionLabel; + keyButton.appendChild(resolutionMetadata); + } + keyButton.addEventListener('click', () => loadRedisExplorerValue(item.key, keyButton)); + keyList.appendChild(keyButton); + }); + } + keyList.scrollTop = 0; + + redisExplorerState.nextCursor = String(payload?.next_cursor || '0'); + document.getElementById('redis-explorer-prev-btn')?.toggleAttribute( + 'disabled', + redisExplorerState.cursorHistory.length === 0 + ); + document.getElementById('redis-explorer-next-btn')?.toggleAttribute( + 'disabled', + !payload?.has_more + ); +} + +function getRedisExplorerRequestState(options = {}) { + const reset = Boolean(options.reset); + const filterInput = document.getElementById('redis-explorer-filter'); + const pageSizeSelect = document.getElementById('redis-explorer-page-size'); + if (reset) { + redisExplorerState.cursor = '0'; + redisExplorerState.nextCursor = '0'; + redisExplorerState.cursorHistory = []; + } + redisExplorerState.filter = filterInput?.value || ''; + redisExplorerState.pageSize = Number(pageSizeSelect?.value || 25); + return { + cursor: redisExplorerState.cursor, + filter: redisExplorerState.filter, + pageSize: redisExplorerState.pageSize + }; +} + +async function loadRedisExplorerKeys(options = {}) { + const triggerButton = options.triggerButton || null; + if (triggerButton) { + setButtonBusy(triggerButton, true, 'Loading...'); + } + setRedisExplorerMessage('Loading Redis keys...', 'info'); + resetRedisExplorerPreview(); + + try { + const requestState = getRedisExplorerRequestState(options); + const query = new URLSearchParams({ + cursor: requestState.cursor, + page_size: String(requestState.pageSize), + filter: requestState.filter + }); + const response = await fetch(`/api/admin/settings/redis-explorer/keys?${query.toString()}`, { + method: 'GET', + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || data.last_error || 'Failed to load Redis keys.'); + } + + renderRedisExplorerKeys(data); + redisExplorerState.loaded = true; + setRedisExplorerMessage(`${getRedisExplorerScopeLabel()} loaded. Select a key to view sanitized preview content.`, 'success'); + } catch (error) { + renderRedisExplorerKeys({ keys: [], has_more: false, next_cursor: '0' }); + setRedisExplorerMessage(error.message || 'Failed to load Redis keys.', 'danger'); + } finally { + if (triggerButton) { + setButtonBusy(triggerButton, false); + } + } +} + +async function loadRedisExplorerValue(key, triggerButton = null) { + if (!key) { + return; + } + if (triggerButton) { + setButtonBusy(triggerButton, true, 'Loading...'); + } + setRedisExplorerMessage('Loading sanitized Redis key preview...', 'info'); + + try { + const response = await fetch('/api/admin/settings/redis-explorer/value', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ key }) + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || data.preview || 'Failed to load Redis key preview.'); + } + + redisExplorerState.selectedKey = key; + setRedisExplorerPreviewVisible(true); + setElementText('redis-explorer-preview-key', data.key || key); + setElementText('redis-explorer-preview-type', formatRedisStatus(data.type || 'unknown')); + setElementText('redis-explorer-preview-ttl', formatRedisExplorerTtl(data.ttl_seconds)); + setElementText('redis-explorer-preview-memory', formatRedisExplorerMemory(data.memory_usage_bytes)); + setElementText( + 'redis-explorer-preview-redacted', + data.preview_restricted ? 'Restricted' : data.redacted ? 'Redacted' : 'Sanitized' + ); + renderRedisExplorerResolution(data.resolution); + setElementText('redis-explorer-preview-content', data.preview || 'No preview available.'); + const previewContent = document.getElementById('redis-explorer-preview-content'); + if (previewContent) { + previewContent.scrollTop = 0; + } + setRedisExplorerMessage( + data.truncated ? 'Preview loaded and truncated to the safe display limit.' : 'Sanitized preview loaded.', + data.preview_restricted || data.redacted || data.truncated ? 'warning' : 'success' + ); + } catch (error) { + setRedisExplorerMessage(error.message || 'Failed to load Redis key preview.', 'danger'); + } finally { + if (triggerButton) { + setButtonBusy(triggerButton, false); + } + } +} + +function setupRedisExplorerControls() { + const modalElement = document.getElementById('redisExplorerModal'); + if (!modalElement) { + return; + } + + modalElement.addEventListener('shown.bs.modal', () => { + if (!redisExplorerState.loaded) { + loadRedisExplorerKeys({ reset: true }); + } + }); + document.getElementById('redis-explorer-search-btn')?.addEventListener('click', event => { + loadRedisExplorerKeys({ reset: true, triggerButton: event.currentTarget }); + }); + document.getElementById('redis-explorer-browse-all-btn')?.addEventListener('click', event => { + const filterInput = document.getElementById('redis-explorer-filter'); + if (filterInput) { + filterInput.value = ''; + } + loadRedisExplorerKeys({ reset: true, triggerButton: event.currentTarget }); + }); + document.getElementById('redis-explorer-refresh-btn')?.addEventListener('click', event => { + loadRedisExplorerKeys({ triggerButton: event.currentTarget }); + }); + document.getElementById('redis-explorer-filter')?.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + loadRedisExplorerKeys({ reset: true }); + } + }); + document.getElementById('redis-explorer-page-size')?.addEventListener('change', () => { + loadRedisExplorerKeys({ reset: true }); + }); + document.getElementById('redis-explorer-next-btn')?.addEventListener('click', event => { + redisExplorerState.cursorHistory.push(redisExplorerState.cursor); + redisExplorerState.cursor = redisExplorerState.nextCursor || '0'; + loadRedisExplorerKeys({ triggerButton: event.currentTarget }); + }); + document.getElementById('redis-explorer-prev-btn')?.addEventListener('click', event => { + redisExplorerState.cursor = redisExplorerState.cursorHistory.pop() || '0'; + loadRedisExplorerKeys({ triggerButton: event.currentTarget }); + }); +} + +function setupRedisMonitoringControls() { + const section = document.getElementById('redis-monitoring-section'); + if (!section) { + return; + } + + document.getElementById('redis-monitoring-refresh-btn')?.addEventListener('click', loadRedisMonitoringStatus); + setupRedisExplorerControls(); + loadRedisMonitoringStatus(null, { showLoading: false }); +} + +function setDocumentAccessIndexMessage(message, variant = 'info') { + const messageElement = document.getElementById('document-access-index-message'); + if (!messageElement) { + return; + } + + messageElement.textContent = message || ''; + messageElement.className = `alert alert-${variant} small mb-3`; + messageElement.classList.toggle('d-none', !message); +} + +function formatDocumentAccessIndexStatus(value) { + const normalizedValue = String(value || '').trim(); + if (!normalizedValue) { + return 'Not loaded'; + } + + return normalizedValue + .replace(/_/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()); +} + +function getDocumentAccessIndexStatusVariant(value) { + const normalizedValue = String(value || '').trim().toLowerCase(); + if (['succeeded', 'skipped_completed', 'completed', 'dry_run_completed', 'reconciled', 'matched', 'aligned'].includes(normalizedValue)) { + return 'success'; + } + if (['running', 'in_progress'].includes(normalizedValue)) { + return 'primary'; + } + if (['succeeded_with_errors', 'completed_with_errors', 'reconciled_with_errors', 'mismatch', 'missing_expected_indexes'].includes(normalizedValue)) { + return 'warning'; + } + if (['failed', 'error'].includes(normalizedValue)) { + return 'danger'; + } + return 'secondary'; +} + +function setDocumentAccessIndexBadge(elementId, value, variant = 'secondary') { + const badge = document.getElementById(elementId); + if (!badge) { + return; + } + + const safeVariants = new Set(['primary', 'secondary', 'success', 'danger', 'warning', 'info']); + badge.textContent = value || 'Not loaded'; + badge.className = `badge text-bg-${safeVariants.has(variant) ? variant : 'secondary'}`; +} + +function formatDocumentAccessIndexBoolean(value, enabledText = 'Enabled', disabledText = 'Disabled') { + return value ? enabledText : disabledText; +} + +function formatDocumentAccessIndexList(items) { + if (!Array.isArray(items) || items.length === 0) { + return 'None'; + } + + return items.map(item => String(item || '').trim()).filter(Boolean).join(', ') || 'None'; +} + +function formatDocumentAccessIndexMetric(value, unit) { + if (value === null || value === undefined || value === '') { + return 'Not available'; + } + const numericValue = Number(value); + if (Number.isNaN(numericValue)) { + return 'Not available'; + } + return `${numericValue.toLocaleString(undefined, { maximumFractionDigits: 3 })} ${unit}`; +} + +function formatDocumentAccessIndexPercent(value) { + if (value === null || value === undefined || value === '') { + return 'Not available'; + } + const numericValue = Number(value); + if (Number.isNaN(numericValue)) { + return 'Not available'; + } + return `${numericValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`; +} + +function formatDocumentAccessIndexMetricPair(sourceValue, projectionValue, unit) { + const sourceMetric = formatDocumentAccessIndexMetric(sourceValue, unit); + const projectionMetric = formatDocumentAccessIndexMetric(projectionValue, unit); + if (sourceMetric === 'Not available' || projectionMetric === 'Not available') { + return 'Not available'; + } + return `${sourceMetric} / ${projectionMetric}`; +} + +function formatDocumentAccessIndexSavings(value, unit, positiveLabel, negativeLabel) { + if (value === null || value === undefined || value === '') { + return 'Not available'; + } + const numericValue = Number(value); + if (Number.isNaN(numericValue)) { + return 'Not available'; + } + if (Math.abs(numericValue) < 0.001) { + return `0 ${unit} difference`; + } + const label = numericValue > 0 ? positiveLabel : negativeLabel; + return `${formatDocumentAccessIndexMetric(Math.abs(numericValue), unit)} ${label}`; +} + +function getDocumentAccessIndexRollingWindow(shadowValidation, windowKey) { + const windows = shadowValidation?.rolling_metrics?.windows || {}; + return windows[windowKey] || {}; +} + +function getDocumentAccessIndexReadWindow(readMetrics, windowKey) { + const windows = readMetrics?.windows || {}; + return windows[windowKey] || {}; +} + +function getDocumentAccessIndexCacheWindow(cacheMetrics, windowKey) { + const windows = cacheMetrics?.windows || {}; + return windows[windowKey] || {}; +} + +function formatConversationCacheOperationCounts(operationCounts) { + const entries = Object.entries(operationCounts || {}) + .filter(([, count]) => Number(count || 0) > 0) + .sort(([left], [right]) => left.localeCompare(right)); + if (entries.length === 0) { + return 'No samples'; + } + + return entries + .map(([operation, count]) => `${formatDocumentAccessIndexStatus(operation)}: ${formatNumber(count)}`) + .join(', '); +} + +function formatDocumentAccessIndexSampleSummary(windowMetrics) { + if (!windowMetrics || Number(windowMetrics.sample_count || 0) === 0) { + return 'No samples'; + } + + const sampleCount = formatNumber(windowMetrics.sample_count || 0); + const matchedCount = formatNumber(windowMetrics.matched_count || 0); + const mismatchCount = formatNumber(windowMetrics.mismatch_count || 0); + const errorCount = formatNumber(windowMetrics.error_count || 0); + const comparableCount = formatNumber(windowMetrics.comparable_sample_count || 0); + return `${sampleCount} samples (${comparableCount} comparable, ${matchedCount} matched, ${mismatchCount} mismatch, ${errorCount} error)`; +} + +function formatDocumentAccessIndexReadSample(sample) { + if (!sample) { + return 'No samples'; + } + const status = formatDocumentAccessIndexStatus(sample.status || 'unknown'); + const operation = formatDocumentAccessIndexStatus(sample.operation || 'read'); + const scope = sample.source_scope ? ` / ${sample.source_scope}` : ''; + return `${status} (${operation}${scope})`; +} + +function formatDocumentAccessIndexCacheEvent(sample) { + if (!sample) { + return 'No cache events'; + } + const eventType = formatDocumentAccessIndexStatus(sample.event_type || 'unknown'); + const operation = formatDocumentAccessIndexStatus(sample.operation || 'cache'); + const reason = sample.reason ? ` / ${formatDocumentAccessIndexStatus(sample.reason)}` : ''; + return `${eventType} (${operation}${reason})`; +} + +function formatDocumentAccessIndexLatencyWindow(windowMetrics) { + const averageLatency = formatDocumentAccessIndexMetric(windowMetrics?.elapsed_ms_avg, 'ms'); + const p95Latency = formatDocumentAccessIndexMetric(windowMetrics?.elapsed_ms_p95, 'ms'); + if (averageLatency === 'Not available' && p95Latency === 'Not available') { + return 'Not available'; + } + return `${averageLatency} / ${p95Latency}`; +} + +function renderConversationCacheStatus(statusPayload) { + const conversationCache = statusPayload?.conversation_cache || {}; + const settings = conversationCache?.settings || {}; + const metrics = conversationCache?.metrics || {}; + const cache15m = getDocumentAccessIndexCacheWindow(metrics, '15m'); + const cacheEnabled = settings.enabled !== false; + const ttlSeconds = Number(settings.ttl_seconds ?? 120); + const runtimeStatus = cacheEnabled + ? (ttlSeconds > 0 ? `Enabled / ${formatDocumentAccessIndexMetric(ttlSeconds, 'sec')}` : 'Enabled / Writes disabled') + : 'Disabled'; + + setDocumentAccessIndexBadge( + 'conversation-cache-runtime-status', + runtimeStatus, + cacheEnabled ? 'success' : 'secondary' + ); + setElementText( + 'conversation-cache-15m-hit-rate', + formatDocumentAccessIndexPercent(cache15m.hit_rate_percent) + ); + setElementText( + 'conversation-cache-15m-hits-misses', + `${formatNumber(cache15m.hit_count || 0)} / ${formatNumber(cache15m.miss_count || 0)}` + ); + setElementText( + 'conversation-cache-15m-bypasses-errors', + `${formatNumber(cache15m.bypass_count || 0)} / ${formatNumber(cache15m.error_count || 0)}` + ); + setElementText( + 'conversation-cache-15m-writes-invalidations', + `${formatNumber(cache15m.write_count || 0)} / ${formatNumber(cache15m.invalidation_count || 0)}` + ); + setElementText( + 'conversation-cache-15m-operation-counts', + formatConversationCacheOperationCounts(cache15m.operation_counts) + ); + setElementText( + 'conversation-cache-last-event', + formatDocumentAccessIndexCacheEvent(metrics.last_event) + ); + setElementText( + 'conversation-cache-last-invalidation', + formatDocumentAccessIndexCacheEvent(metrics.last_invalidation) + ); +} + +function setCosmosMaintenanceMessage(message, variant = 'info') { + const messageElement = document.getElementById('cosmos-maintenance-message'); + if (!messageElement) { + return; + } + + messageElement.textContent = message || ''; + messageElement.className = `alert alert-${variant} small mb-3`; + messageElement.classList.toggle('d-none', !message); +} + +function getCosmosIndexingPolicyStatusText(indexingPolicy) { + if (!indexingPolicy || Object.keys(indexingPolicy).length === 0) { + return 'not_loaded'; + } + if (Number(indexingPolicy.failed_container_count || 0) > 0) { + return 'failed'; + } + if (Number(indexingPolicy.containers_missing_expected_indexes || 0) > 0) { + return 'missing_expected_indexes'; + } + return 'aligned'; +} + +function formatStaleCacheCleanupCategories(categories) { + if (!Array.isArray(categories) || categories.length === 0) { + return 'No categories'; + } + + return categories + .map(category => { + const name = formatDocumentAccessIndexStatus(category.category || 'unknown'); + const candidates = formatNumber(category.candidate_count || 0); + const deleted = formatNumber(category.deleted_count || 0); + const failed = formatNumber(category.failed_count || 0); + return `${name}: ${candidates} candidates / ${deleted} deleted / ${failed} failed`; + }) + .join('; '); +} + +function renderCosmosMaintenanceStatus(statusPayload) { + const indexingPolicy = statusPayload?.cosmos_indexing_policies || {}; + const cleanup = statusPayload?.stale_cache_cleanup || {}; + const indexingStatus = getCosmosIndexingPolicyStatusText(indexingPolicy); + const cleanupStatus = cleanup.status || 'not_run'; + + setDocumentAccessIndexBadge( + 'cosmos-indexing-policy-status', + formatDocumentAccessIndexStatus(indexingStatus), + getDocumentAccessIndexStatusVariant(indexingStatus) + ); + setElementText('cosmos-indexing-policy-mode', formatDocumentAccessIndexStatus(indexingPolicy.mode || 'not_loaded')); + setElementText('cosmos-indexing-policy-container-count', formatNumber(indexingPolicy.container_count || 0)); + setElementText( + 'cosmos-indexing-policy-missing-count', + formatNumber(indexingPolicy.containers_missing_expected_indexes || 0) + ); + setElementText('cosmos-indexing-policy-updated-count', formatNumber(indexingPolicy.updated_container_count || 0)); + setElementText('cosmos-indexing-policy-failed-count', formatNumber(indexingPolicy.failed_container_count || 0)); + setElementText('cosmos-indexing-policy-last-evaluated', indexingPolicy.evaluated_at || 'Not loaded'); + + setDocumentAccessIndexBadge( + 'stale-cache-cleanup-status', + formatDocumentAccessIndexStatus(cleanupStatus), + getDocumentAccessIndexStatusVariant(cleanupStatus) + ); + setElementText('stale-cache-cleanup-mode', formatDocumentAccessIndexStatus(cleanup.mode || 'not_run')); + setElementText('stale-cache-cleanup-candidates', formatNumber(cleanup.candidate_count || 0)); + setElementText('stale-cache-cleanup-deleted', formatNumber(cleanup.deleted_count || 0)); + setElementText('stale-cache-cleanup-failed', formatNumber(cleanup.failed_count || 0)); + setElementText('stale-cache-cleanup-more-candidates', cleanup.has_more_candidates ? 'Yes' : 'No'); + setElementText('stale-cache-cleanup-categories', formatStaleCacheCleanupCategories(cleanup.categories)); + setElementText('stale-cache-cleanup-last-evaluated', cleanup.evaluated_at || 'Not run yet'); +} + +function getStaleCacheCleanupStatusFromRunResult(result) { + const cleanupStep = Array.isArray(result?.steps) + ? result.steps.find(step => step?.name === 'stale_cache_document_cleanup') + : null; + return cleanupStep?.results || null; } -function hasContainerLevelCosmosMetrics(status) { - return (status?.containers || []).some(container => ( - container?.normalized_ru_percent !== null - && container?.normalized_ru_percent !== undefined - ) || ( - container?.request_units !== null - && container?.request_units !== undefined - )); +function getCosmosIndexingPolicyStatusFromRunResult(result) { + const indexingStep = Array.isArray(result?.steps) + ? result.steps.find(step => step?.name === 'cosmos_indexing_policy_maintenance') + : null; + return indexingStep?.results || null; } -function hasPortalManagedCosmosThroughput(status) { - return Boolean(status?.throughput?.portal_managed_scaling_required) || (status?.containers || []).some(container => ( - isCosmosThroughputPortalManaged(container) - )); +function getNormalizedDocumentAccessIndexStatus(status) { + return String(status || '').trim().toLowerCase(); } -function getCachedCosmosThroughputStatus() { - const cachedStatus = window.cosmosThroughputCachedStatus; - if (!cachedStatus || typeof cachedStatus !== 'object' || Array.isArray(cachedStatus)) { - return null; - } +function isDocumentAccessIndexBackfillRunning(status) { + return getNormalizedDocumentAccessIndexStatus(status) === 'running'; +} - const hasStatusData = Boolean( - cachedStatus.last_checked_at - || cachedStatus.capacity_scope - || (Array.isArray(cachedStatus.containers) && cachedStatus.containers.length > 0) - ); - return hasStatusData ? cachedStatus : null; +function isDocumentAccessIndexBackfillInProgress(status) { + return getNormalizedDocumentAccessIndexStatus(status) === 'in_progress'; } -function initializeCosmosThroughputStatusView() { - const cachedStatus = getCachedCosmosThroughputStatus(); - const automationEnabled = isFieldChecked('cosmos_throughput_autoscale_enabled'); +function stopDocumentAccessIndexPolling() { + if (documentAccessIndexStatusPollId) { + window.clearInterval(documentAccessIndexStatusPollId); + documentAccessIndexStatusPollId = null; + } +} - if (cachedStatus) { - updateCosmosThroughputStatusPanel(cachedStatus); - const refreshText = automationEnabled - ? 'Background automation refreshes this saved view on the Metrics Window cadence while enabled.' - : 'Automation is currently disabled; use Refresh to update this saved view.'; - setCosmosThroughputMessage(`Showing last saved Cosmos throughput status. ${refreshText}`, 'info'); +function startDocumentAccessIndexPolling() { + if (documentAccessIndexStatusPollId) { return; } - if (automationEnabled) { - setCosmosThroughputMessage('No saved Cosmos throughput status is available yet. Loading the first status check now...', 'info'); - loadCosmosThroughputStatus(); + documentAccessIndexStatusPollId = window.setInterval(() => { + loadDocumentAccessIndexStatus(null, { showLoading: false }); + }, DOCUMENT_ACCESS_INDEX_STATUS_POLL_INTERVAL_MS); +} + +function getDocumentAccessIndexBackfillStatusFromRunResult(result) { + const backfillStep = Array.isArray(result?.steps) + ? result.steps.find(step => step?.name === 'document_access_index_backfill') + : null; + return backfillStep?.results?.current_status || null; +} + +async function fetchAppMaintenanceStatus(errorMessage = 'Failed to load app maintenance status.') { + const response = await fetch('/api/admin/settings/app-maintenance/status', { + method: 'GET', + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || errorMessage); + } + return data; +} + +function renderDocumentAccessIndexStatus(statusPayload) { + const backfillStatus = statusPayload?.document_access_index_backfill || statusPayload; + const state = backfillStatus?.state || {}; + const settings = backfillStatus?.settings || {}; + const shadowValidation = backfillStatus?.shadow_validation || {}; + const maintenance = backfillStatus?.maintenance || {}; + const readMetrics = backfillStatus?.read_metrics || {}; + const cacheMetrics = backfillStatus?.cache_metrics || {}; + const statusText = String(state.status || 'not_started'); + + setDocumentAccessIndexBadge( + 'document-access-index-container-status', + formatDocumentAccessIndexBoolean(settings.container_enabled, 'Enabled', 'Disabled'), + settings.container_enabled ? 'success' : 'secondary' + ); + setDocumentAccessIndexBadge( + 'document-access-index-write-through-status', + formatDocumentAccessIndexBoolean(settings.write_through_enabled, 'Enabled', 'Disabled'), + settings.write_through_enabled ? 'success' : 'secondary' + ); + setDocumentAccessIndexBadge( + 'document-access-index-read-status', + settings.reads_enabled ? 'Enabled' : 'Disabled', + settings.reads_enabled ? 'success' : 'secondary' + ); + setDocumentAccessIndexBadge( + 'document-access-index-cache-status', + settings.cache_enabled ? `Enabled / ${formatDocumentAccessIndexMetric(settings.cache_ttl_seconds, 'sec')}` : 'Disabled', + settings.cache_enabled ? 'success' : 'secondary' + ); + setDocumentAccessIndexBadge( + 'document-access-index-shadow-status', + settings.shadow_validation_enabled ? 'Enabled' : 'Disabled', + settings.shadow_validation_enabled ? 'warning' : 'secondary' + ); + setDocumentAccessIndexBadge( + 'document-access-index-backfill-status', + formatDocumentAccessIndexStatus(statusText), + getDocumentAccessIndexStatusVariant(statusText) + ); + setDocumentAccessIndexBadge( + 'document-access-index-maintenance-mode', + maintenance.auto_maintenance_enabled ? 'Automatic' : 'Disabled', + maintenance.auto_maintenance_enabled ? 'success' : 'secondary' + ); + + setElementText('document-access-index-repair-count', formatNumber(backfillStatus?.repair_required_count)); + setElementText( + 'document-access-index-maintenance-next-action', + formatDocumentAccessIndexStatus(maintenance.next_action || 'monitor') + ); + setElementText( + 'document-access-index-maintenance-more-work', + maintenance.has_more_work ? 'Yes' : 'No' + ); + setElementText( + 'document-access-index-maintenance-active-interval', + formatDocumentAccessIndexMetric(maintenance.active_interval_seconds, 'sec') + ); + const read15m = getDocumentAccessIndexReadWindow(readMetrics, '15m'); + const cache15m = getDocumentAccessIndexCacheWindow(cacheMetrics, '15m'); + setElementText('document-access-index-read-15m-attempts', formatNumber(read15m.sample_count || 0)); + setElementText( + 'document-access-index-cache-15m-hit-rate', + formatDocumentAccessIndexPercent(cache15m.hit_rate_percent) + ); + setElementText( + 'document-access-index-cache-15m-hits-misses', + `${formatNumber(cache15m.hit_count || 0)} / ${formatNumber(cache15m.miss_count || 0)}` + ); + setElementText( + 'document-access-index-cache-15m-bypasses-errors', + `${formatNumber(cache15m.bypass_count || 0)} / ${formatNumber(cache15m.error_count || 0)}` + ); + setElementText( + 'document-access-index-cache-15m-invalidations', + formatNumber(cache15m.invalidation_count || 0) + ); + setElementText('document-access-index-read-15m-served', formatNumber(read15m.served_from_index_count || 0)); + setElementText('document-access-index-read-15m-fallbacks', formatNumber(read15m.source_fallback_count || 0)); + setElementText( + 'document-access-index-read-15m-fallback-rate', + formatDocumentAccessIndexPercent(read15m.fallback_rate_percent) + ); + setElementText( + 'document-access-index-read-15m-ru', + formatDocumentAccessIndexMetric(read15m.request_charge, 'RU') + ); + setElementText( + 'document-access-index-read-15m-latency', + formatDocumentAccessIndexLatencyWindow(read15m) + ); + setElementText( + 'document-access-index-read-last-fallback', + formatDocumentAccessIndexReadSample(readMetrics.last_fallback_sample) + ); + setElementText( + 'document-access-index-read-last-sample', + formatDocumentAccessIndexReadSample(readMetrics.last_sample) + ); + setElementText( + 'document-access-index-cache-last-event', + formatDocumentAccessIndexCacheEvent(cacheMetrics.last_event) + ); + setDocumentAccessIndexBadge( + 'document-access-index-shadow-last-status', + formatDocumentAccessIndexStatus(shadowValidation.status || 'not_run'), + getDocumentAccessIndexStatusVariant(shadowValidation.status || 'not_run') + ); + setElementText( + 'document-access-index-shadow-mismatches', + `${formatNumber(shadowValidation.missing_count || 0)} missing / ${formatNumber(shadowValidation.extra_count || 0)} extra` + ); + setElementText( + 'document-access-index-shadow-ru-comparison', + formatDocumentAccessIndexMetricPair(shadowValidation.source_query_ru, shadowValidation.validation_index_ru, 'RU') + ); + setElementText( + 'document-access-index-shadow-validation-ru', + formatDocumentAccessIndexMetric(shadowValidation.validation_index_ru, 'RU') + ); + setElementText( + 'document-access-index-shadow-candidate-ru', + formatDocumentAccessIndexMetric(shadowValidation.candidate_read_ru, 'RU') + ); + setElementText( + 'document-access-index-shadow-wave5-ru-savings', + formatDocumentAccessIndexSavings(shadowValidation.estimated_wave5_ru_savings, 'RU', 'less', 'more') + ); + setElementText( + 'document-access-index-shadow-ms-comparison', + formatDocumentAccessIndexMetricPair(shadowValidation.source_query_ms, shadowValidation.candidate_read_ms, 'ms') + ); + setElementText( + 'document-access-index-shadow-ms-savings', + formatDocumentAccessIndexSavings(shadowValidation.estimated_wave5_ms_savings, 'ms', 'faster', 'slower') + ); + const rolling5m = getDocumentAccessIndexRollingWindow(shadowValidation, '5m'); + const rolling15m = getDocumentAccessIndexRollingWindow(shadowValidation, '15m'); + setElementText( + 'document-access-index-rolling-5m-ru-comparison', + formatDocumentAccessIndexMetricPair(rolling5m.source_query_ru, rolling5m.candidate_read_ru, 'RU') + ); + setElementText( + 'document-access-index-rolling-5m-wave5-ru-savings', + formatDocumentAccessIndexSavings(rolling5m.estimated_wave5_ru_savings, 'RU', 'less', 'more') + ); + setElementText( + 'document-access-index-rolling-15m-ru-comparison', + formatDocumentAccessIndexMetricPair(rolling15m.source_query_ru, rolling15m.candidate_read_ru, 'RU') + ); + setElementText( + 'document-access-index-rolling-15m-wave5-ru-savings', + formatDocumentAccessIndexSavings(rolling15m.estimated_wave5_ru_savings, 'RU', 'less', 'more') + ); + setElementText( + 'document-access-index-rolling-15m-validation-overhead', + formatDocumentAccessIndexMetric(rolling15m.validation_index_ru, 'RU') + ); + setElementText( + 'document-access-index-rolling-15m-samples', + formatDocumentAccessIndexSampleSummary(rolling15m) + ); + setElementText('document-access-index-current-scope', state.current_source_scope || 'None'); + setElementText('document-access-index-completed-scopes', formatDocumentAccessIndexList(state.completed_source_scopes)); + setElementText('document-access-index-total-processed', formatNumber(state.total_documents_processed || 0)); + setElementText('document-access-index-total-failed', formatNumber(state.total_documents_failed || 0)); + setElementText('document-access-index-total-upserted', formatNumber(state.total_rows_upserted || 0)); + setElementText('document-access-index-total-deleted', formatNumber(state.total_rows_deleted || 0)); + setElementText('document-access-index-last-completed', state.last_completed_at || 'Not completed yet'); + setElementText('document-access-index-last-error', state.last_error || 'None'); + + const runButton = document.getElementById('document-access-index-run-batch-btn'); + const resetButton = document.getElementById('document-access-index-reset-btn'); + if (runButton) { + runButton.disabled = settings.container_enabled === false; + } + if (resetButton) { + resetButton.disabled = settings.container_enabled === false; + } + + if (isDocumentAccessIndexBackfillRunning(statusText)) { + startDocumentAccessIndexPolling(); + } else { + stopDocumentAccessIndexPolling(); } } -async function loadCosmosThroughputStatus(event = null) { - const triggerButton = event?.currentTarget || document.getElementById('cosmos-throughput-refresh-btn'); +async function loadDocumentAccessIndexStatus(event = null, options = {}) { + const showLoading = options.showLoading !== false; + const triggerButton = event?.currentTarget || (showLoading ? document.getElementById('document-access-index-refresh-btn') : null); + const isConversationCacheTrigger = triggerButton?.id === 'conversation-cache-refresh-btn'; if (triggerButton) { setButtonBusy(triggerButton, true, 'Loading...'); } - setCosmosThroughputMessage('Loading Cosmos throughput status...', 'info'); + if (showLoading) { + setDocumentAccessIndexMessage( + isConversationCacheTrigger ? 'Loading conversation cache metrics...' : 'Loading document access index status...', + 'info' + ); + } try { - const response = await fetch('/api/admin/settings/cosmos-throughput/status', { - method: 'GET', - headers: { 'Accept': 'application/json' }, - credentials: 'same-origin' - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || 'Failed to load Cosmos throughput status.'); - } - - updateCosmosThroughputStatusPanel(data); - if (!data.configured) { - setCosmosThroughputMessage(data.error || 'Cosmos throughput management needs subscription, resource group, account, and database settings.', 'warning'); - } else if (data.throughput_error) { - setCosmosThroughputMessage(`Cosmos database throughput could not be read. ${data.throughput_error}`, 'danger'); - } else if (data.capacity_scope === 'container' && data.metrics?.normalized_ru_percent !== null && data.metrics?.normalized_ru_percent !== undefined && !hasContainerLevelCosmosMetrics(data)) { - setCosmosThroughputMessage('Azure Monitor returned aggregate RU utilization, but not per-container metric dimensions for this window. Container autoscale waits for per-container utilization before scaling individual containers; Refresh again after a few minutes.', 'warning'); - } else if (hasPortalManagedCosmosThroughput(data)) { - setCosmosThroughputMessage('One or more Cosmos throughput targets are above 10,000 RU/s. SimpleChat will monitor utilization and request units only; use the Azure portal for capacity changes, which can take 4 to 6 hours.', 'warning'); - } else if (data.capacity_scope === 'container') { - setCosmosThroughputMessage('Container-targeted throughput is active. Dedicated-throughput containers can be monitored and scaled individually; containers sharing database throughput remain view-only.', 'info'); - } else if (data.metric_error) { - setCosmosThroughputMessage('Throughput loaded, but Azure Monitor metrics are unavailable. Check the app identity permissions and Cosmos metrics availability.', 'warning'); - } else { - setCosmosThroughputMessage('Cosmos throughput status loaded.', 'success'); + const data = await fetchAppMaintenanceStatus('Failed to load document access index status.'); + + renderDocumentAccessIndexStatus(data); + renderConversationCacheStatus(data); + renderCosmosMaintenanceStatus(data); + if (showLoading) { + setDocumentAccessIndexMessage( + isConversationCacheTrigger ? 'Conversation cache metrics loaded.' : 'Document access index status loaded.', + 'success' + ); } } catch (error) { - setCosmosThroughputMessage(error.message || 'Failed to load Cosmos throughput status.', 'danger'); + setDocumentAccessIndexMessage( + error.message || ( + isConversationCacheTrigger + ? 'Failed to load conversation cache metrics.' + : 'Failed to load document access index status.' + ), + 'danger' + ); + stopDocumentAccessIndexPolling(); } finally { if (triggerButton) { setButtonBusy(triggerButton, false); @@ -1442,123 +2722,206 @@ async function loadCosmosThroughputStatus(event = null) { } } -function buildCosmosThroughputAccessPayload() { - return { - cosmos_throughput_autoscale_enabled: isFieldChecked('cosmos_throughput_autoscale_enabled'), - cosmos_throughput_auto_scale_up_enabled: isFieldChecked('cosmos_throughput_auto_scale_up_enabled'), - cosmos_throughput_auto_scale_down_enabled: isFieldChecked('cosmos_throughput_auto_scale_down_enabled'), - cosmos_throughput_subscription_id: getFieldValue('cosmos_throughput_subscription_id'), - cosmos_throughput_resource_group: getFieldValue('cosmos_throughput_resource_group'), - cosmos_throughput_account_name: getFieldValue('cosmos_throughput_account_name'), - cosmos_throughput_database_name: getFieldValue('cosmos_throughput_database_name'), - cosmos_throughput_metrics_window_minutes: getNumericFieldValue('cosmos_throughput_metrics_window_minutes', 5), - cosmos_throughput_scale_up_threshold_percent: getNumericFieldValue('cosmos_throughput_scale_up_threshold_percent', 90), - cosmos_throughput_scale_down_threshold_percent: getNumericFieldValue('cosmos_throughput_scale_down_threshold_percent', 70), - cosmos_throughput_scale_up_step_ru: getNumericFieldValue('cosmos_throughput_scale_up_step_ru', 1000), - cosmos_throughput_scale_down_step_ru: getNumericFieldValue('cosmos_throughput_scale_down_step_ru', 1000), - cosmos_throughput_scale_up_cooldown_minutes: getNumericFieldValue('cosmos_throughput_scale_up_cooldown_minutes', 5), - cosmos_throughput_scale_down_cooldown_minutes: getNumericFieldValue('cosmos_throughput_scale_down_cooldown_minutes', 20), - cosmos_throughput_min_ru: getNumericFieldValue('cosmos_throughput_min_ru', 1000), - cosmos_throughput_max_ru: getNumericFieldValue('cosmos_throughput_max_ru', COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU), - cosmos_throughput_ignore_min_limit: isFieldChecked('cosmos_throughput_ignore_min_limit'), - cosmos_throughput_ignore_max_limit: isFieldChecked('cosmos_throughput_ignore_max_limit'), - cosmos_throughput_convert_manual_to_autoscale_enabled: isFieldChecked('cosmos_throughput_convert_manual_to_autoscale_enabled'), - cosmos_throughput_enforce_container_defaults: isFieldChecked('cosmos_throughput_enforce_container_defaults'), - cosmos_throughput_container_policies: collectCosmosContainerPolicies() - }; +async function loadCosmosMaintenanceStatus(event = null, options = {}) { + const showLoading = options.showLoading !== false; + const triggerButton = event?.currentTarget || (showLoading ? document.getElementById('cosmos-maintenance-refresh-btn') : null); + if (triggerButton) { + setButtonBusy(triggerButton, true, 'Loading...'); + } + if (showLoading) { + setCosmosMaintenanceMessage('Loading Cosmos maintenance status...', 'info'); + } + + try { + const data = await fetchAppMaintenanceStatus('Failed to load Cosmos maintenance status.'); + renderDocumentAccessIndexStatus(data); + renderConversationCacheStatus(data); + renderCosmosMaintenanceStatus(data); + if (showLoading) { + setCosmosMaintenanceMessage('Cosmos maintenance status loaded.', 'success'); + } + } catch (error) { + setCosmosMaintenanceMessage(error.message || 'Failed to load Cosmos maintenance status.', 'danger'); + } finally { + if (triggerButton) { + setButtonBusy(triggerButton, false); + } + } } -async function validateCosmosThroughputAccess(triggerButton = null) { - const button = triggerButton || document.getElementById('cosmos-throughput-validate-access-btn'); - if (button) { - setButtonBusy(button, true, 'Validating...'); +async function runStaleCacheCleanup(options = {}) { + const applyChanges = Boolean(options.applyChanges); + const triggerButton = options.triggerButton || document.getElementById( + applyChanges ? 'stale-cache-cleanup-apply-confirm-btn' : 'stale-cache-cleanup-dry-run-btn' + ); + if (triggerButton) { + setButtonBusy(triggerButton, true, applyChanges ? 'Deleting...' : 'Scanning...'); } - setCosmosThroughputMessage('Validating Cosmos throughput configuration and access...', 'info'); + setCosmosMaintenanceMessage( + applyChanges ? 'Deleting one bounded batch of stale cache documents...' : 'Scanning for stale cache cleanup candidates...', + 'info' + ); try { - const response = await fetch('/api/admin/settings/cosmos-throughput/validate-access', { + const response = await fetch('/api/admin/settings/app-maintenance/run', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify(buildCosmosThroughputAccessPayload()) + body: JSON.stringify({ + apply_cosmos_indexing_policies: false, + run_document_access_index_backfill: false, + run_stale_cache_cleanup: true, + apply_stale_cache_cleanup: applyChanges + }) }); const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || 'Failed to validate Cosmos throughput access.'); + const cleanupResult = getStaleCacheCleanupStatusFromRunResult(data); + if (cleanupResult) { + renderCosmosMaintenanceStatus({ stale_cache_cleanup: cleanupResult }); + } + if (!response.ok || !data.success) { + throw new Error(data.error || 'Stale cache cleanup failed.'); } - if (data.status?.configured) { - updateCosmosThroughputStatusPanel(data.status); + const candidates = formatNumber(cleanupResult?.candidate_count || 0); + const deleted = formatNumber(cleanupResult?.deleted_count || 0); + const message = applyChanges + ? `Stale cache cleanup deleted ${deleted} document(s) from ${candidates} candidate(s).` + : `Stale cache cleanup dry run found ${candidates} candidate document(s).`; + setCosmosMaintenanceMessage(message, cleanupResult?.has_more_candidates ? 'warning' : 'success'); + showToast(message, cleanupResult?.has_more_candidates ? 'warning' : 'success'); + + const modalElement = document.getElementById('staleCacheCleanupApplyModal'); + if (applyChanges && modalElement) { + bootstrap.Modal.getInstance(modalElement)?.hide(); } - setCosmosThroughputValidationResult(data); + await loadCosmosMaintenanceStatus(null, { showLoading: false }); } catch (error) { - setCosmosThroughputMessage(error.message || 'Failed to validate Cosmos throughput access.', 'danger'); + setCosmosMaintenanceMessage(error.message || 'Stale cache cleanup failed.', 'danger'); + showToast(error.message || 'Stale cache cleanup failed.', 'danger'); } finally { - if (button) { - setButtonBusy(button, false); + if (triggerButton) { + setButtonBusy(triggerButton, false); } } } -async function manuallyScaleCosmosThroughput(direction, containerName = '', triggerButton = null) { - const button = triggerButton || document.getElementById(`cosmos-throughput-scale-${direction}-btn`); - if (button) { - setButtonBusy(button, true, direction === 'up' ? 'Scaling up...' : 'Scaling down...'); +async function runCosmosIndexingPolicyApply(options = {}) { + const triggerButton = options.triggerButton || document.getElementById('cosmos-indexing-policy-apply-confirm-btn'); + if (triggerButton) { + setButtonBusy(triggerButton, true, 'Applying...'); } - const targetText = containerName ? ` for ${containerName}` : ''; - setCosmosThroughputMessage(direction === 'up' ? `Submitting scale-up request${targetText}...` : `Submitting scale-down request${targetText}...`, 'info'); + setCosmosMaintenanceMessage( + 'Submitting missing Cosmos composite index updates. Index transformation may continue asynchronously in Azure Cosmos DB.', + 'info' + ); try { - const response = await fetch('/api/admin/settings/cosmos-throughput/scale', { + const response = await fetch('/api/admin/settings/app-maintenance/run', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, credentials: 'same-origin', - body: JSON.stringify({ direction, container_name: containerName }) + body: JSON.stringify({ + apply_cosmos_indexing_policies: true, + run_document_access_index_backfill: false, + run_stale_cache_cleanup: false + }) }); const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || 'Cosmos throughput scale request failed.'); + const indexingResult = getCosmosIndexingPolicyStatusFromRunResult(data); + if (indexingResult) { + renderCosmosMaintenanceStatus({ cosmos_indexing_policies: indexingResult }); + } + if (!response.ok || !data.success) { + throw new Error(data.error || 'Cosmos indexing policy update failed.'); } - const scaledTarget = data.container_name ? ` for ${data.container_name}` : ''; - setCosmosThroughputMessage(`Cosmos throughput${scaledTarget} changed from ${formatRu(data.from_ru)} to ${formatRu(data.to_ru)}.`, 'success'); - await loadCosmosThroughputStatus(); + const updated = formatNumber(indexingResult?.updated_container_count || 0); + const missing = formatNumber(indexingResult?.containers_missing_expected_indexes || 0); + const message = Number(indexingResult?.updated_container_count || 0) > 0 + ? `Cosmos indexing policy update submitted for ${updated} container(s). Refresh later to monitor transformation status.` + : `Cosmos indexing policies are already aligned. Missing expected indexes: ${missing}.`; + setCosmosMaintenanceMessage(message, 'success'); + showToast(message, 'success'); + + const modalElement = document.getElementById('cosmosIndexingPolicyApplyModal'); + if (modalElement) { + bootstrap.Modal.getInstance(modalElement)?.hide(); + } + await loadCosmosMaintenanceStatus(null, { showLoading: false }); } catch (error) { - setCosmosThroughputMessage(error.message || 'Cosmos throughput scale request failed.', 'danger'); + setCosmosMaintenanceMessage(error.message || 'Cosmos indexing policy update failed.', 'danger'); + showToast(error.message || 'Cosmos indexing policy update failed.', 'danger'); } finally { - if (button) { - setButtonBusy(button, false, direction === 'up' ? 'Scale Up' : 'Scale Down'); + if (triggerButton) { + setButtonBusy(triggerButton, false); } } } -async function convertCosmosThroughputToAutoscale(containerName = '', triggerButton = null) { - const targetText = containerName ? ` for ${containerName}` : ''; +async function runDocumentAccessIndexBackfillBatch(options = {}) { + const reset = Boolean(options.reset); + const triggerButton = options.triggerButton || document.getElementById(reset ? 'document-access-index-reset-confirm-btn' : 'document-access-index-run-batch-btn'); if (triggerButton) { - setButtonBusy(triggerButton, true, 'Converting...'); + setButtonBusy(triggerButton, true, reset ? 'Resetting...' : 'Running...'); } - setCosmosThroughputMessage(`Converting manual Cosmos throughput${targetText} to native autoscale...`, 'info'); + setDocumentAccessIndexMessage(reset ? 'Resetting backfill checkpoint and running one batch...' : 'Running one document access backfill batch...', 'info'); try { - const response = await fetch('/api/admin/settings/cosmos-throughput/convert-autoscale', { + const response = await fetch('/api/admin/settings/app-maintenance/run', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, credentials: 'same-origin', - body: JSON.stringify({ container_name: containerName }) + body: JSON.stringify({ + apply_cosmos_indexing_policies: false, + run_document_access_index_backfill: true, + reset_document_access_index_backfill: reset + }) }); const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || 'Cosmos throughput mode conversion failed.'); + const currentStatus = getDocumentAccessIndexBackfillStatusFromRunResult(data); + if (currentStatus) { + renderDocumentAccessIndexStatus(currentStatus); + } + if (!response.ok || !data.success) { + throw new Error(data.error || 'Document access index backfill batch failed.'); + } + + const status = currentStatus?.state?.status || 'completed'; + const statusVariant = getDocumentAccessIndexStatusVariant(status); + if (isDocumentAccessIndexBackfillRunning(status)) { + setDocumentAccessIndexMessage('Backfill batch is still running. Status will refresh automatically.', 'info'); + } else if (isDocumentAccessIndexBackfillInProgress(status)) { + setDocumentAccessIndexMessage('Backfill batch completed and more documents remain. Run another batch or leave scheduled backfill enabled to continue during maintenance.', 'info'); + } else if (statusVariant === 'danger' || statusVariant === 'warning') { + setDocumentAccessIndexMessage('Backfill batch completed with errors. Review the repair backlog and last error details.', 'warning'); + } else { + setDocumentAccessIndexMessage('Document access index backfill batch completed.', 'success'); } + showToast( + statusVariant === 'danger' || statusVariant === 'warning' + ? 'Document access index backfill batch completed with errors.' + : 'Document access index backfill batch completed.', + statusVariant === 'danger' ? 'danger' : statusVariant === 'warning' ? 'warning' : 'success' + ); - const convertedTarget = data.container_name ? ` for ${data.container_name}` : ''; - setCosmosThroughputMessage(`Cosmos throughput${convertedTarget} converted from manual ${formatRu(data.from_ru)} to autoscale max ${formatRu(data.to_ru)}.`, 'success'); - await loadCosmosThroughputStatus(); + const modalElement = document.getElementById('documentAccessIndexResetModal'); + if (reset && modalElement) { + bootstrap.Modal.getInstance(modalElement)?.hide(); + } } catch (error) { - setCosmosThroughputMessage(error.message || 'Cosmos throughput mode conversion failed.', 'danger'); + setDocumentAccessIndexMessage(error.message || 'Document access index backfill batch failed.', 'danger'); + showToast(error.message || 'Document access index backfill batch failed.', 'danger'); + stopDocumentAccessIndexPolling(); } finally { if (triggerButton) { setButtonBusy(triggerButton, false); @@ -1566,6 +2929,41 @@ async function convertCosmosThroughputToAutoscale(containerName = '', triggerBut } } +function setupDocumentAccessIndexControls() { + const section = document.getElementById('document-access-index-section'); + if (!section) { + return; + } + + document.getElementById('document-access-index-refresh-btn')?.addEventListener('click', loadDocumentAccessIndexStatus); + document.getElementById('conversation-cache-refresh-btn')?.addEventListener('click', loadDocumentAccessIndexStatus); + document.getElementById('document-access-index-run-batch-btn')?.addEventListener('click', event => { + runDocumentAccessIndexBackfillBatch({ triggerButton: event.currentTarget }); + }); + document.getElementById('document-access-index-reset-confirm-btn')?.addEventListener('click', event => { + runDocumentAccessIndexBackfillBatch({ reset: true, triggerButton: event.currentTarget }); + }); + loadDocumentAccessIndexStatus(null, { showLoading: false }); +} + +function setupCosmosMaintenanceControls() { + const section = document.getElementById('cosmos-maintenance-section'); + if (!section) { + return; + } + + document.getElementById('cosmos-maintenance-refresh-btn')?.addEventListener('click', loadCosmosMaintenanceStatus); + document.getElementById('cosmos-indexing-policy-apply-confirm-btn')?.addEventListener('click', event => { + runCosmosIndexingPolicyApply({ triggerButton: event.currentTarget }); + }); + document.getElementById('stale-cache-cleanup-dry-run-btn')?.addEventListener('click', event => { + runStaleCacheCleanup({ applyChanges: false, triggerButton: event.currentTarget }); + }); + document.getElementById('stale-cache-cleanup-apply-confirm-btn')?.addEventListener('click', event => { + runStaleCacheCleanup({ applyChanges: true, triggerButton: event.currentTarget }); + }); +} + function setupCosmosThroughputControls() { const section = document.getElementById('cosmos-throughput-section'); if (!section) { @@ -2943,6 +4341,9 @@ document.addEventListener('DOMContentLoaded', () => { // --- NEW: Chunk size controls --- setupChunkSizeControls(); + setupRedisMonitoringControls(); + setupCosmosMaintenanceControls(); + setupDocumentAccessIndexControls(); setupCosmosThroughputControls(); // --- Setup form change tracking --- @@ -4528,10 +5929,10 @@ function setupToggles() { const enableRedisCache = document.getElementById('enable_redis_cache'); const redisSettingsDiv = document.getElementById('redis_cache_settings'); if (enableRedisCache && redisSettingsDiv) { - // Set initial state - redisSettingsDiv.style.display = enableRedisCache.checked ? 'block' : 'none'; + updateRedisCanonicalCacheVisibility(enableRedisCache.checked); enableRedisCache.addEventListener('change', function () { - redisSettingsDiv.style.display = this.checked ? 'block' : 'none'; + updateRedisCanonicalCacheVisibility(this.checked); + markFormAsModified(); }); } @@ -5758,7 +7159,7 @@ function setupTestButtons() { if (testRedisBtn) { testRedisBtn.addEventListener('click', async () => { const resultDiv = document.getElementById('test_redis_result'); - resultDiv.innerHTML = 'Testing Redis...'; + setRedisTestResult(resultDiv, 'Testing Redis...', 'muted'); const payload = { test_type: 'redis', @@ -5775,12 +7176,13 @@ function setupTestButtons() { }); const data = await resp.json(); if (resp.ok) { - resultDiv.innerHTML = `${data.message}`; + setRedisTestResult(resultDiv, data.message || 'Redis connection successful.', 'success'); + loadRedisMonitoringStatus(null, { showLoading: false }); } else { - resultDiv.innerHTML = `${data.error || 'Error testing Redis'}`; + setRedisTestResult(resultDiv, data.error || 'Error testing Redis', 'danger'); } } catch (err) { - resultDiv.innerHTML = `Error: ${err.message}`; + setRedisTestResult(resultDiv, `Error: ${err.message}`, 'danger'); } }); } @@ -6334,7 +7736,7 @@ function updateRedisCanonicalCacheVisibility(isEnabled) { const redisSettingsDiv = document.getElementById('redis_cache_settings'); if (redisSettingsDiv) { - redisSettingsDiv.style.display = isEnabled ? 'block' : 'none'; + redisSettingsDiv.classList.toggle('d-none', !isEnabled); } } diff --git a/application/single_app/static/js/admin/admin_sidebar_nav.js b/application/single_app/static/js/admin/admin_sidebar_nav.js index 21bc0db7..0b9c9d60 100644 --- a/application/single_app/static/js/admin/admin_sidebar_nav.js +++ b/application/single_app/static/js/admin/admin_sidebar_nav.js @@ -1,3 +1,4 @@ +// admin_sidebar_nav.js // Admin Sidebar Navigation document.addEventListener('DOMContentLoaded', function() { // Only initialize if we're on admin settings page with sidebar nav @@ -201,6 +202,11 @@ function scrollToSection(sectionId) { 'file-processing-logs-section': 'file-processing-logs-section', // Scale tab sections 'redis-cache-section': 'redis-cache-section', + 'redis-monitoring-section': 'redis-monitoring-section', + 'document-access-index-section': 'document-access-index-section', + 'cosmos-maintenance-section': 'cosmos-maintenance-section', + 'cosmos-throughput-section': 'cosmos-throughput-section', + 'cosmos-throughput-metrics-table-section': 'cosmos-throughput-metrics-table-section', 'front-door-section': 'front-door-section', // Workspaces tab sections 'personal-workspaces-section': 'personal-workspaces-section', diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index 77dd90e6..c5e8027e 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -1698,7 +1698,7 @@ export async function selectConversation(conversationId) { } catch (error) { console.warn('Failed to reattach active stream for conversation:', error); } - markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => { + markConversationRead(conversationId, { suppressErrorToast: true }).catch(error => { console.warn('Failed to clear unread state for conversation:', error); }); } diff --git a/application/single_app/static/js/chat/chat-streaming.js b/application/single_app/static/js/chat/chat-streaming.js index 24d6b89f..c08a1cf0 100644 --- a/application/single_app/static/js/chat/chat-streaming.js +++ b/application/single_app/static/js/chat/chat-streaming.js @@ -74,6 +74,21 @@ function getStreamingMessageElement(messageId) { return document.querySelector(`[data-message-id="${messageId}"]`); } +function isConversationCurrentlyActive(conversationId) { + const normalizedConversationId = String(conversationId || '').trim(); + return Boolean(normalizedConversationId) && window.currentConversationId === normalizedConversationId; +} + +function markStreamingConversationReadIfActive(conversationId, contextLabel) { + if (!isConversationCurrentlyActive(conversationId)) { + return; + } + + markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => { + console.warn(`Failed to clear unread state after ${contextLabel}:`, error); + }); +} + function buildDefaultCancelEndpoint(conversationId) { const normalizedConversationId = String(conversationId || '').trim(); if (!normalizedConversationId) { @@ -1148,11 +1163,7 @@ function finalizeCancelledStreamingMessage(messageId, userMessageId, finalData, renderStoppedContent(messageElement, partialContent); } - if (finalData.conversation_id) { - markConversationRead(finalData.conversation_id, { force: true, suppressErrorToast: true }).catch(error => { - console.warn('Failed to clear unread state after stream cancellation:', error); - }); - } + markStreamingConversationReadIfActive(finalData.conversation_id, 'stream cancellation'); } function normalizeFallbackAgentIcon(iconPayload) { @@ -1252,11 +1263,7 @@ function finalizeStreamingMessage(messageId, userMessageId, finalData, fallbackA } if (existingFinalMessage) { - if (finalData.conversation_id) { - markConversationRead(finalData.conversation_id, { force: true, suppressErrorToast: true }).catch(error => { - console.warn('Failed to clear unread state after live streaming completion:', error); - }); - } + markStreamingConversationReadIfActive(finalData.conversation_id, 'live streaming completion'); return; } @@ -1307,7 +1314,7 @@ function finalizeStreamingMessage(messageId, userMessageId, finalData, fallbackA ); // Update conversation if needed - if (finalData.conversation_id && window.currentConversationId !== finalData.conversation_id) { + if (finalData.conversation_id && !window.currentConversationId) { window.currentConversationId = finalData.conversation_id; } @@ -1348,11 +1355,7 @@ function finalizeStreamingMessage(messageId, userMessageId, finalData, fallbackA window.chatMessages.loadMessages(finalData.conversation_id); } - if (finalData.conversation_id) { - markConversationRead(finalData.conversation_id, { force: true, suppressErrorToast: true }).catch(error => { - console.warn('Failed to clear unread state after live streaming completion:', error); - }); - } + markStreamingConversationReadIfActive(finalData.conversation_id, 'live streaming completion'); } export function cancelStreaming() { diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index aedab427..9e80ba11 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -579,6 +579,31 @@ Redis Cache + + + + +