codexLB-up 1.25.0b10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +10 -0
- app/admin_cli.py +494 -0
- app/cli.py +339 -0
- app/codex_sessions_retag.py +487 -0
- app/core/__init__.py +0 -0
- app/core/audit/__init__.py +0 -0
- app/core/audit/service.py +216 -0
- app/core/audit/types.py +94 -0
- app/core/auth/__init__.py +236 -0
- app/core/auth/api_key_cache.py +65 -0
- app/core/auth/dashboard_access.py +429 -0
- app/core/auth/dashboard_mode.py +171 -0
- app/core/auth/dashboard_session_ttl.py +74 -0
- app/core/auth/dashboard_users_cache.py +120 -0
- app/core/auth/dependencies.py +710 -0
- app/core/auth/external_identity.py +78 -0
- app/core/auth/guardian.py +374 -0
- app/core/auth/models.py +49 -0
- app/core/auth/providers/__init__.py +129 -0
- app/core/auth/providers/oidc.py +838 -0
- app/core/auth/providers/registry.py +118 -0
- app/core/auth/refresh.py +394 -0
- app/core/auth/step_up.py +117 -0
- app/core/auth/totp.py +81 -0
- app/core/balancer/__init__.py +71 -0
- app/core/balancer/logic.py +1612 -0
- app/core/balancer/rendezvous_hash.py +21 -0
- app/core/balancer/types.py +21 -0
- app/core/bootstrap.py +172 -0
- app/core/cache/invalidation.py +426 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/codex.py +996 -0
- app/core/clients/codex_version.py +187 -0
- app/core/clients/codex_version_snapshot.py +2 -0
- app/core/clients/files.py +534 -0
- app/core/clients/headers.py +29 -0
- app/core/clients/http.py +511 -0
- app/core/clients/model_fetcher.py +276 -0
- app/core/clients/native_egress.py +1304 -0
- app/core/clients/oauth.py +477 -0
- app/core/clients/proxy.py +6089 -0
- app/core/clients/proxy_websocket.py +1411 -0
- app/core/clients/rate_limit_reset_credits.py +412 -0
- app/core/clients/stream_errors.py +12 -0
- app/core/clients/thread_cache_identity.py +347 -0
- app/core/clients/usage.py +467 -0
- app/core/clock.py +170 -0
- app/core/config/__init__.py +0 -0
- app/core/config/background_jobs.py +73 -0
- app/core/config/context_window_overrides.py +166 -0
- app/core/config/dashboard_overrides.py +145 -0
- app/core/config/inheritable.py +47 -0
- app/core/config/key_fingerprint.py +115 -0
- app/core/config/settings.py +872 -0
- app/core/config/settings_cache.py +100 -0
- app/core/config/spool_retention.py +217 -0
- app/core/config/tiers.py +174 -0
- app/core/conversation_archive.py +568 -0
- app/core/crypto.py +37 -0
- app/core/draining.py +11 -0
- app/core/errors.py +365 -0
- app/core/exceptions.py +155 -0
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +478 -0
- app/core/http_protocol.py +176 -0
- app/core/http_protocol_httptools.py +204 -0
- app/core/ingress_limits.py +17 -0
- app/core/metadata_store.py +18 -0
- app/core/metrics/__init__.py +0 -0
- app/core/metrics/middleware.py +74 -0
- app/core/metrics/prometheus.py +604 -0
- app/core/middleware/__init__.py +25 -0
- app/core/middleware/api_firewall.py +111 -0
- app/core/middleware/app_version.py +37 -0
- app/core/middleware/dashboard_auth_proxy.py +55 -0
- app/core/middleware/dashboard_csrf.py +177 -0
- app/core/middleware/dashboard_gzip.py +41 -0
- app/core/middleware/dashboard_overrides.py +59 -0
- app/core/middleware/firewall_cache.py +91 -0
- app/core/middleware/inflight.py +126 -0
- app/core/middleware/multipart_content_encoding.py +129 -0
- app/core/middleware/path_rewrite.py +148 -0
- app/core/middleware/request_body_limit.py +196 -0
- app/core/middleware/request_decompression.py +209 -0
- app/core/middleware/request_id.py +72 -0
- app/core/middleware/required_capability_http.py +116 -0
- app/core/middleware/trusted_proxy_headers.py +36 -0
- app/core/multipart.py +237 -0
- app/core/multipart_fields.py +91 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/chat_requests.py +493 -0
- app/core/openai/chat_responses.py +931 -0
- app/core/openai/contracts.py +46 -0
- app/core/openai/exceptions.py +24 -0
- app/core/openai/host_models.py +14 -0
- app/core/openai/images.py +413 -0
- app/core/openai/message_coercion.py +376 -0
- app/core/openai/model_refresh_scheduler.py +423 -0
- app/core/openai/model_registry.py +997 -0
- app/core/openai/model_registry_store.py +548 -0
- app/core/openai/models.py +172 -0
- app/core/openai/parsing.py +97 -0
- app/core/openai/requests.py +1980 -0
- app/core/openai/strict_schema.py +199 -0
- app/core/openai/tool_call_safety.py +99 -0
- app/core/openai/v1_requests.py +187 -0
- app/core/plan_types.py +90 -0
- app/core/prestop.py +202 -0
- app/core/rate_limiter/__init__.py +0 -0
- app/core/rate_limiter/db_rate_limiter.py +215 -0
- app/core/request_locality.py +473 -0
- app/core/resilience/__init__.py +0 -0
- app/core/resilience/backpressure.py +57 -0
- app/core/resilience/bulkhead.py +202 -0
- app/core/resilience/circuit_breaker.py +150 -0
- app/core/resilience/degradation.py +55 -0
- app/core/resilience/loop_lag_monitor.py +60 -0
- app/core/resilience/memory_monitor.py +137 -0
- app/core/resilience/network_recovery.py +256 -0
- app/core/resilience/overload.py +98 -0
- app/core/resilience/toggles.py +114 -0
- app/core/retention/__init__.py +0 -0
- app/core/retention/job.py +240 -0
- app/core/retention/scheduler.py +69 -0
- app/core/runtime_logging.py +601 -0
- app/core/scheduling/__init__.py +0 -0
- app/core/scheduling/leader_election.py +993 -0
- app/core/scheduling/leader_election_handle.py +25 -0
- app/core/server.py +140 -0
- app/core/shutdown.py +326 -0
- app/core/socket_peer.py +22 -0
- app/core/startup.py +41 -0
- app/core/timeout_invariants.py +347 -0
- app/core/tracing/__init__.py +0 -0
- app/core/tracing/otel.py +207 -0
- app/core/types.py +6 -0
- app/core/upstream_proxy/__init__.py +13 -0
- app/core/upstream_proxy/cache.py +130 -0
- app/core/upstream_proxy/resolver.py +189 -0
- app/core/upstream_proxy/types.py +104 -0
- app/core/usage/__init__.py +407 -0
- app/core/usage/depletion.py +109 -0
- app/core/usage/live_hub.py +41 -0
- app/core/usage/live_snapshots.py +174 -0
- app/core/usage/logs.py +212 -0
- app/core/usage/metadata_scheduler.py +118 -0
- app/core/usage/models.py +54 -0
- app/core/usage/pricing.py +576 -0
- app/core/usage/pricing_catalog.py +241 -0
- app/core/usage/pricing_snapshot.json +821 -0
- app/core/usage/quota.py +133 -0
- app/core/usage/refresh_policy.py +21 -0
- app/core/usage/refresh_scheduler.py +713 -0
- app/core/usage/reset_credits_refresh_scheduler.py +435 -0
- app/core/usage/types.py +168 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/json_guards.py +18 -0
- app/core/utils/locks.py +33 -0
- app/core/utils/masking.py +20 -0
- app/core/utils/proxy_env.py +104 -0
- app/core/utils/request_id.py +65 -0
- app/core/utils/retry.py +77 -0
- app/core/utils/shared_future.py +172 -0
- app/core/utils/sse.py +286 -0
- app/core/utils/time.py +24 -0
- app/db/__init__.py +0 -0
- app/db/account_identity_lock.py +56 -0
- app/db/alembic/__init__.py +1 -0
- app/db/alembic/env.py +70 -0
- app/db/alembic/revision_ids.py +43 -0
- app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
- app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
- app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
- app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
- app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
- app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
- app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
- app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
- app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
- app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
- app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
- app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
- app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
- app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
- app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
- app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
- app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
- app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
- app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
- app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
- app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
- app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
- app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
- app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
- app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
- app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
- app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
- app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
- app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
- app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
- app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
- app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
- app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
- app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
- app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
- app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
- app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
- app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
- app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
- app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
- app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
- app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
- app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
- app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
- app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
- app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
- app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
- app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
- app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
- app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
- app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
- app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
- app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
- app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
- app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
- app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
- app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
- app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
- app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
- app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
- app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
- app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
- app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
- app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
- app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
- app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
- app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
- app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
- app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
- app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
- app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
- app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
- app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
- app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
- app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
- app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
- app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
- app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
- app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
- app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
- app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
- app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
- app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
- app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
- app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
- app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
- app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
- app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
- app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
- app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
- app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
- app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
- app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
- app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
- app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
- app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
- app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
- app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
- app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
- app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
- app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
- app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
- app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
- app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
- app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
- app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
- app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
- app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
- app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
- app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
- app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
- app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
- app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
- app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
- app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
- app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
- app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
- app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
- app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
- app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
- app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
- app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
- app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
- app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
- app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
- app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
- app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
- app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
- app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
- app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
- app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
- app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
- app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
- app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
- app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
- app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
- app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
- app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
- app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
- app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
- app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
- app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
- app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
- app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
- app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
- app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
- app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
- app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
- app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
- app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
- app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
- app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
- app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
- app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
- app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
- app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
- app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
- app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
- app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
- app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
- app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
- app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
- app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
- app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
- app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
- app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
- app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
- app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
- app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
- app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
- app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
- app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
- app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
- app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
- app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
- app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
- app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
- app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
- app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
- app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
- app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
- app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
- app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
- app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
- app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
- app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
- app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
- app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
- app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
- app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
- app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
- app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
- app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
- app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
- app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
- app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
- app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
- app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
- app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
- app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
- app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
- app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
- app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
- app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
- app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
- app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
- app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
- app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
- app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
- app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
- app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
- app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
- app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
- app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
- app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
- app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
- app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
- app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
- app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
- app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
- app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
- app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
- app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
- app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
- app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
- app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
- app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
- app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
- app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
- app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
- app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
- app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
- app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
- app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
- app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
- app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
- app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
- app/db/alembic/versions/__init__.py +1 -0
- app/db/backup.py +64 -0
- app/db/migrate.py +1249 -0
- app/db/migration_lock.py +160 -0
- app/db/migration_url.py +18 -0
- app/db/models.py +3100 -0
- app/db/recover.py +244 -0
- app/db/session.py +1295 -0
- app/db/snapshot.py +46 -0
- app/db/sqlite_lock_retry.py +190 -0
- app/db/sqlite_utils.py +549 -0
- app/dependencies.py +462 -0
- app/main.py +1240 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +426 -0
- app/modules/accounts/auth_manager.py +1439 -0
- app/modules/accounts/background_repository.py +160 -0
- app/modules/accounts/deletion.py +540 -0
- app/modules/accounts/mappers.py +654 -0
- app/modules/accounts/refresh_claims.py +352 -0
- app/modules/accounts/repository.py +1679 -0
- app/modules/accounts/schemas.py +308 -0
- app/modules/accounts/service.py +809 -0
- app/modules/accounts/usage_rollup.py +486 -0
- app/modules/accounts/usage_rollup_scheduler.py +75 -0
- app/modules/accounts/usage_time_rollup.py +1037 -0
- app/modules/accounts/usage_time_rollup_read.py +522 -0
- app/modules/api_keys/__init__.py +1 -0
- app/modules/api_keys/api.py +344 -0
- app/modules/api_keys/last_used_coalescer.py +216 -0
- app/modules/api_keys/limit_windows.py +41 -0
- app/modules/api_keys/repository.py +1200 -0
- app/modules/api_keys/reset_scheduler.py +85 -0
- app/modules/api_keys/schemas.py +135 -0
- app/modules/api_keys/service.py +2123 -0
- app/modules/audit/__init__.py +0 -0
- app/modules/audit/api.py +88 -0
- app/modules/audit/repository.py +74 -0
- app/modules/audit/schemas.py +33 -0
- app/modules/audit/service.py +71 -0
- app/modules/auth_providers/__init__.py +0 -0
- app/modules/auth_providers/api.py +127 -0
- app/modules/auth_providers/config.py +343 -0
- app/modules/auth_providers/repository.py +101 -0
- app/modules/auth_providers/schemas.py +88 -0
- app/modules/auth_providers/seed.py +105 -0
- app/modules/auth_providers/service.py +289 -0
- app/modules/automations/__init__.py +1 -0
- app/modules/automations/api.py +369 -0
- app/modules/automations/repository.py +1896 -0
- app/modules/automations/scheduler.py +107 -0
- app/modules/automations/schemas.py +158 -0
- app/modules/automations/service.py +2548 -0
- app/modules/cache_isolation_probe/__init__.py +0 -0
- app/modules/cache_isolation_probe/api.py +188 -0
- app/modules/cache_isolation_probe/prefix.py +144 -0
- app/modules/cache_isolation_probe/schemas.py +94 -0
- app/modules/cache_isolation_probe/sender.py +229 -0
- app/modules/cache_isolation_probe/service.py +525 -0
- app/modules/conversation_archive/__init__.py +1 -0
- app/modules/conversation_archive/api.py +101 -0
- app/modules/conversation_archive/schemas.py +28 -0
- app/modules/conversation_archive/service.py +165 -0
- app/modules/dashboard/__init__.py +1 -0
- app/modules/dashboard/api.py +106 -0
- app/modules/dashboard/builders.py +107 -0
- app/modules/dashboard/repository.py +304 -0
- app/modules/dashboard/schemas.py +148 -0
- app/modules/dashboard/service.py +534 -0
- app/modules/dashboard/timeframes.py +19 -0
- app/modules/dashboard/weekly_pace.py +617 -0
- app/modules/dashboard_auth/__init__.py +1 -0
- app/modules/dashboard_auth/api.py +1344 -0
- app/modules/dashboard_auth/oidc_api.py +753 -0
- app/modules/dashboard_auth/oidc_flows.py +330 -0
- app/modules/dashboard_auth/repository.py +452 -0
- app/modules/dashboard_auth/schemas.py +232 -0
- app/modules/dashboard_auth/service.py +1384 -0
- app/modules/dashboard_roles/__init__.py +0 -0
- app/modules/dashboard_roles/api.py +64 -0
- app/modules/dashboard_roles/repository.py +54 -0
- app/modules/dashboard_roles/schemas.py +29 -0
- app/modules/dashboard_roles/seed.py +80 -0
- app/modules/dashboard_roles/service.py +142 -0
- app/modules/dashboard_users/__init__.py +0 -0
- app/modules/dashboard_users/api.py +278 -0
- app/modules/dashboard_users/break_glass.py +169 -0
- app/modules/dashboard_users/credentials.py +18 -0
- app/modules/dashboard_users/identity_resolver.py +665 -0
- app/modules/dashboard_users/repository.py +725 -0
- app/modules/dashboard_users/schemas.py +117 -0
- app/modules/dashboard_users/service.py +1054 -0
- app/modules/firewall/__init__.py +1 -0
- app/modules/firewall/api.py +78 -0
- app/modules/firewall/repository.py +51 -0
- app/modules/firewall/schemas.py +28 -0
- app/modules/firewall/service.py +93 -0
- app/modules/fleet/__init__.py +1 -0
- app/modules/fleet/api.py +177 -0
- app/modules/fleet/mappers.py +56 -0
- app/modules/fleet/observability.py +372 -0
- app/modules/fleet/schemas.py +119 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +246 -0
- app/modules/health/schemas.py +27 -0
- app/modules/limit_warmup/__init__.py +1 -0
- app/modules/limit_warmup/repository.py +159 -0
- app/modules/limit_warmup/service.py +1001 -0
- app/modules/model_sources/__init__.py +1 -0
- app/modules/model_sources/api.py +99 -0
- app/modules/model_sources/catalog.py +307 -0
- app/modules/model_sources/forwarding.py +1435 -0
- app/modules/model_sources/projection.py +55 -0
- app/modules/model_sources/repository.py +201 -0
- app/modules/model_sources/schemas.py +79 -0
- app/modules/model_sources/selection.py +166 -0
- app/modules/model_sources/service.py +236 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +101 -0
- app/modules/oauth/repository.py +238 -0
- app/modules/oauth/schemas.py +49 -0
- app/modules/oauth/service.py +1196 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/_load_balancer/__init__.py +1 -0
- app/modules/proxy/_load_balancer/error_rate.py +116 -0
- app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
- app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
- app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
- app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
- app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
- app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
- app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
- app/modules/proxy/_load_balancer/tunables.py +97 -0
- app/modules/proxy/_load_balancer/types.py +96 -0
- app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
- app/modules/proxy/_service/__init__.py +6 -0
- app/modules/proxy/_service/api_key_usage.py +745 -0
- app/modules/proxy/_service/clock_budget.py +38 -0
- app/modules/proxy/_service/codex_control.py +611 -0
- app/modules/proxy/_service/compact.py +2211 -0
- app/modules/proxy/_service/file_ops.py +749 -0
- app/modules/proxy/_service/http_bridge/__init__.py +3 -0
- app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
- app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
- app/modules/proxy/_service/http_bridge/activity.py +178 -0
- app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
- app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
- app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
- app/modules/proxy/_service/http_bridge/protocol.py +167 -0
- app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
- app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
- app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
- app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
- app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
- app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
- app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
- app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
- app/modules/proxy/_service/observability.py +410 -0
- app/modules/proxy/_service/rate_limit.py +361 -0
- app/modules/proxy/_service/realtime_live.py +655 -0
- app/modules/proxy/_service/refresh.py +154 -0
- app/modules/proxy/_service/request_log.py +585 -0
- app/modules/proxy/_service/response_create.py +930 -0
- app/modules/proxy/_service/streaming/__init__.py +3 -0
- app/modules/proxy/_service/streaming/helpers.py +1317 -0
- app/modules/proxy/_service/streaming/mixin.py +1100 -0
- app/modules/proxy/_service/streaming/protocol.py +32 -0
- app/modules/proxy/_service/streaming/retry.py +3762 -0
- app/modules/proxy/_service/support.py +2122 -0
- app/modules/proxy/_service/transcribe.py +434 -0
- app/modules/proxy/_service/warmup.py +483 -0
- app/modules/proxy/_service/websocket/__init__.py +3 -0
- app/modules/proxy/_service/websocket/helpers.py +2360 -0
- app/modules/proxy/_service/websocket/mixin.py +7285 -0
- app/modules/proxy/_service/websocket/protocol.py +74 -0
- app/modules/proxy/account_cache.py +229 -0
- app/modules/proxy/account_eligibility.py +87 -0
- app/modules/proxy/additional_model_limits.py +40 -0
- app/modules/proxy/affinity.py +830 -0
- app/modules/proxy/affinity_observation.py +101 -0
- app/modules/proxy/api.py +10254 -0
- app/modules/proxy/api_key_usage.py +113 -0
- app/modules/proxy/cap_partitioning.py +280 -0
- app/modules/proxy/capability_lineage.py +65 -0
- app/modules/proxy/capability_lineage_repository.py +82 -0
- app/modules/proxy/capability_routing.py +253 -0
- app/modules/proxy/continuity.py +112 -0
- app/modules/proxy/downstream_delivery.py +277 -0
- app/modules/proxy/durable_bridge_coordinator.py +1217 -0
- app/modules/proxy/durable_bridge_repository.py +4516 -0
- app/modules/proxy/durable_bridge_runtime.py +9 -0
- app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
- app/modules/proxy/fair_share.py +131 -0
- app/modules/proxy/file_pin_repository.py +210 -0
- app/modules/proxy/helpers.py +439 -0
- app/modules/proxy/http_bridge_event_batcher.py +613 -0
- app/modules/proxy/http_bridge_forwarding.py +797 -0
- app/modules/proxy/http_continuation.py +76 -0
- app/modules/proxy/images_observability.py +62 -0
- app/modules/proxy/images_service.py +1054 -0
- app/modules/proxy/load_balancer.py +3016 -0
- app/modules/proxy/rate_limit_cache.py +46 -0
- app/modules/proxy/replay_safety.py +1063 -0
- app/modules/proxy/repo_bundle.py +31 -0
- app/modules/proxy/request_policy.py +1054 -0
- app/modules/proxy/ring_membership.py +251 -0
- app/modules/proxy/schemas.py +345 -0
- app/modules/proxy/selection_errors.py +41 -0
- app/modules/proxy/service.py +2600 -0
- app/modules/proxy/source_admission.py +125 -0
- app/modules/proxy/source_dispatch.py +999 -0
- app/modules/proxy/sticky_repository.py +831 -0
- app/modules/proxy/thread_anchors.py +491 -0
- app/modules/proxy/tool_call_dedupe.py +802 -0
- app/modules/proxy/types.py +54 -0
- app/modules/proxy/work_admission.py +121 -0
- app/modules/quota_planner/__init__.py +1 -0
- app/modules/quota_planner/api.py +276 -0
- app/modules/quota_planner/logic.py +743 -0
- app/modules/quota_planner/repository.py +722 -0
- app/modules/quota_planner/scheduler.py +212 -0
- app/modules/quota_planner/schemas.py +102 -0
- app/modules/quota_planner/warmup.py +693 -0
- app/modules/rate_limit_reset_credits/__init__.py +0 -0
- app/modules/rate_limit_reset_credits/api.py +601 -0
- app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
- app/modules/rate_limit_reset_credits/store.py +114 -0
- app/modules/reports/__init__.py +1 -0
- app/modules/reports/api.py +134 -0
- app/modules/reports/cache.py +74 -0
- app/modules/reports/filters.py +27 -0
- app/modules/reports/repository.py +525 -0
- app/modules/reports/rollup.py +150 -0
- app/modules/reports/rollup_read.py +148 -0
- app/modules/reports/schemas.py +123 -0
- app/modules/reports/service.py +287 -0
- app/modules/reports/thread_identity.py +256 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +224 -0
- app/modules/request_logs/cost_backfill.py +154 -0
- app/modules/request_logs/mappers.py +97 -0
- app/modules/request_logs/repository.py +1765 -0
- app/modules/request_logs/schemas.py +145 -0
- app/modules/request_logs/service.py +348 -0
- app/modules/role_mappings/__init__.py +0 -0
- app/modules/role_mappings/api.py +206 -0
- app/modules/role_mappings/matching.py +55 -0
- app/modules/role_mappings/repository.py +106 -0
- app/modules/role_mappings/schemas.py +67 -0
- app/modules/role_mappings/service.py +334 -0
- app/modules/runtime/__init__.py +1 -0
- app/modules/runtime/api.py +18 -0
- app/modules/runtime/schemas.py +14 -0
- app/modules/runtime/service.py +218 -0
- app/modules/scim/__init__.py +1 -0
- app/modules/scim/api.py +194 -0
- app/modules/scim/dependencies.py +112 -0
- app/modules/scim/errors.py +77 -0
- app/modules/scim/management_api.py +156 -0
- app/modules/scim/repository.py +212 -0
- app/modules/scim/schemas.py +145 -0
- app/modules/scim/service.py +786 -0
- app/modules/scim/tokens.py +43 -0
- app/modules/settings/__init__.py +1 -0
- app/modules/settings/api.py +1677 -0
- app/modules/settings/repository.py +606 -0
- app/modules/settings/schemas.py +496 -0
- app/modules/settings/service.py +769 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +22 -0
- app/modules/sticky_sessions/__init__.py +1 -0
- app/modules/sticky_sessions/api.py +118 -0
- app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
- app/modules/sticky_sessions/schemas.py +78 -0
- app/modules/sticky_sessions/service.py +196 -0
- app/modules/telemetry/__init__.py +5 -0
- app/modules/telemetry/api.py +115 -0
- app/modules/telemetry/clients.py +62 -0
- app/modules/telemetry/consent.py +115 -0
- app/modules/telemetry/scheduler.py +85 -0
- app/modules/telemetry/schemas.py +163 -0
- app/modules/telemetry/sender.py +221 -0
- app/modules/telemetry/snapshot.py +490 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/additional_quota_keys.py +379 -0
- app/modules/usage/api.py +45 -0
- app/modules/usage/background_repository.py +147 -0
- app/modules/usage/builders.py +559 -0
- app/modules/usage/depletion_service.py +277 -0
- app/modules/usage/live_ingest.py +356 -0
- app/modules/usage/mappers.py +29 -0
- app/modules/usage/plan_downgrade_observations.py +488 -0
- app/modules/usage/repository.py +1890 -0
- app/modules/usage/schemas.py +68 -0
- app/modules/usage/service.py +102 -0
- app/modules/usage/updater.py +1488 -0
- app/static/assets/Trans-Cj-J9mVA.js +1 -0
- app/static/assets/access-page-DHzZiXZi.js +1 -0
- app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
- app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
- app/static/assets/account-status-DIsU5sUS.js +1 -0
- app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
- app/static/assets/accounts-page-Bbai8ift.js +3 -0
- app/static/assets/api-Dwa7XxPe.js +1 -0
- app/static/assets/api-client-dugX1Pti.js +55 -0
- app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
- app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
- app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
- app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
- app/static/assets/apis-page-BvXz9c6i.js +2 -0
- app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
- app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
- app/static/assets/automations-page-DQgNAJOR.js +3 -0
- app/static/assets/badge-DqMb9es0.js +1 -0
- app/static/assets/blur-email-HEvKagG2.js +1 -0
- app/static/assets/button-BjVmmiGW.js +1 -0
- app/static/assets/checkbox-apsl9vzk.js +1 -0
- app/static/assets/chevron-left-1d_pHVa5.js +1 -0
- app/static/assets/circle-alert-BS0xd_o1.js +1 -0
- app/static/assets/clsx-DB0hHKMi.js +1 -0
- app/static/assets/colors-BBy8GEtr.js +1 -0
- app/static/assets/copy-button-D7nAvOKR.js +1 -0
- app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
- app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
- app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
- app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
- app/static/assets/donut-chart-Bxc69cra.js +1 -0
- app/static/assets/download-kQteIcsB.js +1 -0
- app/static/assets/ellipsis-B_5OgIre.js +1 -0
- app/static/assets/empty-state-JcA7tc8m.js +1 -0
- app/static/assets/es2015-bMH8YKZT.js +41 -0
- app/static/assets/es6-B264ni6W.js +69 -0
- app/static/assets/external-link-UVAOgMQ0.js +1 -0
- app/static/assets/i18n-BWqZ9Ua2.js +1 -0
- app/static/assets/inbox-B_RM2s1J.js +1 -0
- app/static/assets/index-DIpd264Z.js +23 -0
- app/static/assets/index-DMeLKRJx.css +2 -0
- app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
- app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
- app/static/assets/iso-Bpe5Ihbb.js +1 -0
- app/static/assets/label-CGaLmLNf.js +1 -0
- app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
- app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
- app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
- app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
- app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
- app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
- app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
- app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
- app/static/assets/payload-CIYnGZz9.js +1 -0
- app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
- app/static/assets/report-chart-card-BXDliwcc.js +1 -0
- app/static/assets/reports-page-CGCJeMHG.js +3 -0
- app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
- app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
- app/static/assets/search-HfhqwERt.js +1 -0
- app/static/assets/select-Cl33bz-g.js +1 -0
- app/static/assets/settings-page-mZj01HYl.js +2 -0
- app/static/assets/shim-CZwyM4Q7.js +1 -0
- app/static/assets/skeleton-C_EUc4Rp.js +1 -0
- app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
- app/static/assets/status-badge-CHHA5X6c.js +1 -0
- app/static/assets/switch-CzxgokcM.js +1 -0
- app/static/assets/table-DXaNSQ0G.js +1 -0
- app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
- app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
- app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
- app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
- app/static/assets/trash-D3KyWr1h.js +1 -0
- app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
- app/static/assets/use-accounts-PWlVGw3z.js +1 -0
- app/static/assets/use-auth-CuDlW3iC.js +1 -0
- app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
- app/static/assets/use-model-sources-Bul24JBu.js +1 -0
- app/static/assets/use-models-DUCPUuZg.js +1 -0
- app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
- app/static/assets/useTranslation--56nHhlH.js +1 -0
- app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
- app/static/assets/users-uIC3TIlE.js +1 -0
- app/static/assets/vendor-query-_bHZuxEx.js +1 -0
- app/static/assets/vendor-react-BVL0YZPW.js +9 -0
- app/static/favicon.svg +10 -0
- app/static/fonts/GeistSans-Variable.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
- app/static/index.html +29 -0
- codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
- codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
- codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
- codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
- codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
- config/additional_quota_registry.json +11 -0
app/db/session.py
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Protocol, TypeVar
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
from anyio import to_thread
|
|
16
|
+
from sqlalchemy import event, text
|
|
17
|
+
from sqlalchemy import util as sqlalchemy_util
|
|
18
|
+
from sqlalchemy.engine import Connection, Engine
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
20
|
+
from sqlalchemy.pool import NullPool
|
|
21
|
+
|
|
22
|
+
from app.core.config.settings import get_settings
|
|
23
|
+
from app.core.utils.shared_future import _await_cleanup_deferring_cancellation
|
|
24
|
+
from app.db.sqlite_utils import (
|
|
25
|
+
IntegrityCheck,
|
|
26
|
+
SqliteFileIdentity,
|
|
27
|
+
SqliteIntegrityCheckMode,
|
|
28
|
+
SqliteRunState,
|
|
29
|
+
SqliteRunStateDurabilityError,
|
|
30
|
+
_sqlite_file_identity,
|
|
31
|
+
acquire_sqlite_runstate_lock,
|
|
32
|
+
check_sqlite_integrity,
|
|
33
|
+
integrity_check_pragma_name,
|
|
34
|
+
normalize_sqlite_url,
|
|
35
|
+
read_sqlite_runstate_record,
|
|
36
|
+
release_sqlite_runstate_lock,
|
|
37
|
+
sqlite_db_path_from_url,
|
|
38
|
+
sqlite_url_is_memory,
|
|
39
|
+
write_sqlite_runstate,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from app.db.migrate import MigrationRunResult, MigrationState
|
|
44
|
+
|
|
45
|
+
_settings = get_settings()
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
_SQLITE_BUSY_TIMEOUT_MS = 30_000
|
|
50
|
+
_SQLITE_BUSY_TIMEOUT_SECONDS = _SQLITE_BUSY_TIMEOUT_MS / 1000
|
|
51
|
+
# A write transaction holding SQLite's single writer slot past the busy
|
|
52
|
+
# timeout is exactly the holder that makes every other writer surface
|
|
53
|
+
# "database is locked" (issue #1682); the watchdog below reports it with the
|
|
54
|
+
# statements it ran, since the stall is nondeterministic and self-recovers.
|
|
55
|
+
_SQLITE_LONG_WRITE_TRANSACTION_WARN_SECONDS = _SQLITE_BUSY_TIMEOUT_SECONDS
|
|
56
|
+
_SQLITE_WRITE_STATEMENT_PREFIXES = (
|
|
57
|
+
"insert",
|
|
58
|
+
"update",
|
|
59
|
+
"delete",
|
|
60
|
+
"replace",
|
|
61
|
+
"create",
|
|
62
|
+
"drop",
|
|
63
|
+
"alter",
|
|
64
|
+
"vacuum",
|
|
65
|
+
# BEGIN IMMEDIATE/EXCLUSIVE acquire the writer slot with no DML at all
|
|
66
|
+
# (e.g. AccountsRepository._acquire_sqlite_merge_lock); a plain deferred
|
|
67
|
+
# BEGIN does not and stays untracked.
|
|
68
|
+
"begin immediate",
|
|
69
|
+
"begin exclusive",
|
|
70
|
+
)
|
|
71
|
+
_SQLITE_WATCHDOG_STATEMENT_PREVIEW_CHARS = 300
|
|
72
|
+
# Initial observation bound for file-backed SQLite teardown. A pending worker
|
|
73
|
+
# can retain the single writer slot (issue #1682); after the completion grace,
|
|
74
|
+
# reclamation attempts to interrupt the driver and invalidate its connection.
|
|
75
|
+
_SQLITE_TEARDOWN_TIMEOUT_SECONDS = _SQLITE_BUSY_TIMEOUT_SECONDS / 6
|
|
76
|
+
# A worker can finish while loop callbacks remain queued (issue #2029). Allow
|
|
77
|
+
# a separate bounded opportunity to observe successful task completion before
|
|
78
|
+
# reclamation. A genuinely pending teardown can take this additional 0.75s.
|
|
79
|
+
_SQLITE_TEARDOWN_COMPLETION_GRACE_SECONDS = _SQLITE_BUSY_TIMEOUT_SECONDS / 40
|
|
80
|
+
# Session.info marker set once a teardown step was abandoned as wedged: the
|
|
81
|
+
# session must never be driven by another coroutine again (the abandoned
|
|
82
|
+
# greenlet may still resume), and the deferred cleanup takes over.
|
|
83
|
+
_SQLITE_TEARDOWN_WEDGED_INFO_KEY = "sqlite_teardown_wedged"
|
|
84
|
+
# Abandoned wedged teardown tasks and the deferred bookkeeping closes they
|
|
85
|
+
# schedule on late completion, owned until completion so shutdown (close_db)
|
|
86
|
+
# drains them instead of closing the event loop over pending tasks. The
|
|
87
|
+
# bookkeeping closes are bounded by _SQLITE_TEARDOWN_TIMEOUT_SECONDS; an
|
|
88
|
+
# abandoned teardown may outlive its reclaim (the interrupt is best-effort),
|
|
89
|
+
# so the close_db drain is explicitly bounded as a whole.
|
|
90
|
+
_wedged_teardown_cleanup_tasks: set[asyncio.Task[Any]] = set()
|
|
91
|
+
|
|
92
|
+
# PostgreSQL pool checkout timeout and connection recycle window. Fixed
|
|
93
|
+
# application constants (issue #1340): recycle keeps pooled connections
|
|
94
|
+
# younger than any reasonable server/proxy keep-alive boundary, and neither
|
|
95
|
+
# value is an operator decision. Pool sizing stays configurable via
|
|
96
|
+
# ``database_pool_size`` / ``database_max_overflow``.
|
|
97
|
+
_POSTGRES_POOL_TIMEOUT_SECONDS = 30.0
|
|
98
|
+
_POSTGRES_POOL_RECYCLE_SECONDS = 1800
|
|
99
|
+
# Per-statement execution bound (issue #1971). One query hung on a half-dead
|
|
100
|
+
# connection wedged the process-global settings-cache lock — and every
|
|
101
|
+
# http-bridge submit parked behind it while holding its session's
|
|
102
|
+
# pending_lock — for days. No runtime query legitimately runs this long
|
|
103
|
+
# (retention and cleanup work is chunked); Alembic migrations use their own
|
|
104
|
+
# synchronous engine and are not bounded by this. Fixed application constant
|
|
105
|
+
# like the pool knobs above: not an operator decision.
|
|
106
|
+
_POSTGRES_COMMAND_TIMEOUT_SECONDS = 60.0
|
|
107
|
+
_database_url = normalize_sqlite_url(_settings.database_url)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _PostgresPooledEngineRole(StrEnum):
|
|
111
|
+
REQUEST_PATH = "request_path"
|
|
112
|
+
BACKGROUND_TASK = "background_task"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# The owned launcher pins one worker per replica. Derive its two-engine
|
|
116
|
+
# PostgreSQL budget from the roles that the real creation paths must declare.
|
|
117
|
+
_POSTGRES_POOLED_ENGINE_ROLES = tuple(_PostgresPooledEngineRole)
|
|
118
|
+
_POSTGRES_POOLED_ENGINES_PER_WORKER = len(_POSTGRES_POOLED_ENGINE_ROLES)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_sqlite_url(url: str) -> bool:
|
|
122
|
+
return url.startswith("sqlite+aiosqlite:///") or url.startswith("sqlite:///")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_sqlite_memory_url(url: str) -> bool:
|
|
126
|
+
return sqlite_url_is_memory(url)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _postgres_async_connect_args(url: str) -> dict[str, object] | None:
|
|
130
|
+
if not url.startswith("postgresql+asyncpg://"):
|
|
131
|
+
return None
|
|
132
|
+
# Pin the asyncpg session time zone to UTC. The application persists naive
|
|
133
|
+
# UTC datetimes (see app.core.utils.time.utcnow) into timestamptz columns.
|
|
134
|
+
# asyncpg binds a naive datetime using the connection's session time zone,
|
|
135
|
+
# so if that time zone follows the container's TZ (e.g. Europe/Amsterdam)
|
|
136
|
+
# PostgreSQL shifts every written timestamp away from real UTC. That skew is
|
|
137
|
+
# silent but corrupts every wall-clock comparison the coordinator relies on:
|
|
138
|
+
# ring-membership heartbeats look perpetually stale, leader election and the
|
|
139
|
+
# bridge-session cleanup stop running, and account/stream lease expiry is
|
|
140
|
+
# mis-evaluated. Forcing UTC keeps stored timestamps correct regardless of
|
|
141
|
+
# the container time zone.
|
|
142
|
+
connect_args: dict[str, object] = {
|
|
143
|
+
"server_settings": {"timezone": "UTC"},
|
|
144
|
+
# Bound every statement so a query stalled on a half-dead connection
|
|
145
|
+
# cannot hold application locks forever (issue #1971). asyncpg cancels
|
|
146
|
+
# the statement server-side and raises, surfacing the stall as an
|
|
147
|
+
# error instead of an unbounded await.
|
|
148
|
+
"command_timeout": _POSTGRES_COMMAND_TIMEOUT_SECONDS,
|
|
149
|
+
}
|
|
150
|
+
if os.environ.get("CODEX_LB_TEST_DATABASE_URL"):
|
|
151
|
+
connect_args["prepared_statement_cache_size"] = 0
|
|
152
|
+
return connect_args
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _postgres_async_engine_kwargs(url: str) -> dict[str, object]:
|
|
156
|
+
"""Engine kwargs shared by the main and background PostgreSQL engines.
|
|
157
|
+
|
|
158
|
+
The background engine always derives its pool sizing from the main pool
|
|
159
|
+
settings; it exists to isolate background-task checkouts, not to be sized
|
|
160
|
+
independently.
|
|
161
|
+
"""
|
|
162
|
+
connect_args = _postgres_async_connect_args(url)
|
|
163
|
+
kwargs: dict[str, object] = {"connect_args": connect_args or {}}
|
|
164
|
+
if os.environ.get("CODEX_LB_TEST_DATABASE_URL") and url.startswith("postgresql+asyncpg://"):
|
|
165
|
+
kwargs["poolclass"] = NullPool
|
|
166
|
+
else:
|
|
167
|
+
kwargs["pool_size"] = _settings.database_pool_size
|
|
168
|
+
kwargs["max_overflow"] = _settings.database_max_overflow
|
|
169
|
+
kwargs["pool_timeout"] = _POSTGRES_POOL_TIMEOUT_SECONDS
|
|
170
|
+
# Detect server-side connection drops (idle timeout, restart, network reset)
|
|
171
|
+
# before the first real query, and cycle long-lived connections so they
|
|
172
|
+
# never reach an upstream keep-alive boundary. SQLite paths do not need
|
|
173
|
+
# either knob — aiosqlite has no analogous server-side disconnect.
|
|
174
|
+
kwargs["pool_pre_ping"] = True
|
|
175
|
+
kwargs["pool_recycle"] = _POSTGRES_POOL_RECYCLE_SECONDS
|
|
176
|
+
return kwargs
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _create_postgres_async_engine(url: str, *, role: _PostgresPooledEngineRole) -> AsyncEngine:
|
|
180
|
+
if not isinstance(role, _PostgresPooledEngineRole):
|
|
181
|
+
raise TypeError(f"PostgreSQL engine role must be declared in {_PostgresPooledEngineRole.__name__}")
|
|
182
|
+
return create_async_engine(
|
|
183
|
+
url,
|
|
184
|
+
echo=False,
|
|
185
|
+
**_postgres_async_engine_kwargs(url),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _sqlite_file_async_engine_kwargs() -> dict[str, object]:
|
|
190
|
+
return {
|
|
191
|
+
"poolclass": NullPool,
|
|
192
|
+
"connect_args": {"timeout": _SQLITE_BUSY_TIMEOUT_SECONDS},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _configure_sqlite_engine(engine: Engine, *, enable_wal: bool) -> None:
|
|
197
|
+
@event.listens_for(engine, "connect")
|
|
198
|
+
def _set_sqlite_pragmas(dbapi_connection: sqlite3.Connection, _: object) -> None:
|
|
199
|
+
cursor: sqlite3.Cursor = dbapi_connection.cursor()
|
|
200
|
+
try:
|
|
201
|
+
if enable_wal:
|
|
202
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
203
|
+
cursor.execute("PRAGMA synchronous=NORMAL")
|
|
204
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
205
|
+
cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
|
|
206
|
+
finally:
|
|
207
|
+
cursor.close()
|
|
208
|
+
|
|
209
|
+
_install_sqlite_long_write_watchdog(engine)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _current_task_name_best_effort() -> str:
|
|
213
|
+
# Sync engine events run inside the greenlet driving the async engine on
|
|
214
|
+
# the event loop thread, so the owning task is normally visible; never let
|
|
215
|
+
# diagnostics raise into the query path.
|
|
216
|
+
try:
|
|
217
|
+
task = asyncio.current_task()
|
|
218
|
+
except RuntimeError:
|
|
219
|
+
return "<no-loop>"
|
|
220
|
+
if task is None:
|
|
221
|
+
return "<no-task>"
|
|
222
|
+
return task.get_name()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _install_sqlite_long_write_watchdog(engine: Engine) -> None:
|
|
226
|
+
"""Report write transactions that outlive the SQLite busy timeout.
|
|
227
|
+
|
|
228
|
+
In WAL mode a transaction takes the single writer slot at its first write
|
|
229
|
+
statement, not at BEGIN, so the window is measured from the first write to
|
|
230
|
+
commit/rollback. The report fires when the holder finally ends — the stall
|
|
231
|
+
in issue #1682 self-recovers, so identifying the holder post-hoc is the
|
|
232
|
+
point; a live sampler is not needed to attribute it.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def _live_connection_info(conn: object) -> dict[str, object] | None:
|
|
236
|
+
# Connection.info may reconnect an invalidated driver connection. A
|
|
237
|
+
# cancelled statement requires rollback first; diagnostics must never
|
|
238
|
+
# prevent that rollback with PendingRollbackError or ResourceClosedError.
|
|
239
|
+
if getattr(conn, "invalidated", False) or getattr(conn, "closed", False):
|
|
240
|
+
return None
|
|
241
|
+
return getattr(conn, "info", None)
|
|
242
|
+
|
|
243
|
+
@event.listens_for(engine, "after_cursor_execute")
|
|
244
|
+
def _track_write_statements(
|
|
245
|
+
conn: object,
|
|
246
|
+
cursor: object,
|
|
247
|
+
statement: str,
|
|
248
|
+
parameters: object,
|
|
249
|
+
context: object,
|
|
250
|
+
executemany: bool,
|
|
251
|
+
) -> None:
|
|
252
|
+
# after_cursor_execute, not before: the first write statement may wait
|
|
253
|
+
# up to busy_timeout for the writer slot before failing, and timing
|
|
254
|
+
# from before the execute would report that victim as the holder. The
|
|
255
|
+
# slot is only held once a write statement has SUCCEEDED, so the clock
|
|
256
|
+
# starts there.
|
|
257
|
+
stripped = statement.lstrip().lower()
|
|
258
|
+
if not stripped.startswith(_SQLITE_WRITE_STATEMENT_PREFIXES):
|
|
259
|
+
return
|
|
260
|
+
info = _live_connection_info(conn)
|
|
261
|
+
if info is None:
|
|
262
|
+
return
|
|
263
|
+
preview = statement[:_SQLITE_WATCHDOG_STATEMENT_PREVIEW_CHARS]
|
|
264
|
+
if "sqlite_write_started_at" not in info:
|
|
265
|
+
info["sqlite_write_started_at"] = time.monotonic()
|
|
266
|
+
info["sqlite_first_write_statement"] = preview
|
|
267
|
+
info["sqlite_write_task"] = _current_task_name_best_effort()
|
|
268
|
+
info["sqlite_last_write_statement"] = preview
|
|
269
|
+
|
|
270
|
+
def _finalize_pending_report(info: dict[str, object]) -> None:
|
|
271
|
+
pending = info.pop("sqlite_write_pending_report", None)
|
|
272
|
+
if not isinstance(pending, tuple):
|
|
273
|
+
return
|
|
274
|
+
started_at, outcome, first_statement, last_statement, task_name = pending
|
|
275
|
+
held_seconds = time.monotonic() - float(started_at)
|
|
276
|
+
if held_seconds < _SQLITE_LONG_WRITE_TRANSACTION_WARN_SECONDS:
|
|
277
|
+
return
|
|
278
|
+
logger.warning(
|
|
279
|
+
"sqlite_long_write_transaction held_seconds=%.1f outcome=%s task=%s first_statement=%r "
|
|
280
|
+
"last_statement=%r — this writer starved every other writer past busy_timeout (issue #1682)",
|
|
281
|
+
held_seconds,
|
|
282
|
+
outcome,
|
|
283
|
+
task_name,
|
|
284
|
+
first_statement,
|
|
285
|
+
last_statement,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _mark_transaction_ending(conn: object, *, outcome: str) -> None:
|
|
289
|
+
# ConnectionEvents.commit/rollback fire BEFORE the DBAPI call, and a
|
|
290
|
+
# wedged rollback is exactly the holder this watchdog hunts — reporting
|
|
291
|
+
# here would exclude the wedge itself from the measured hold. Stash the
|
|
292
|
+
# report and finalize it at the first proof the DBAPI transaction ended:
|
|
293
|
+
# the connection's next transaction beginning, or the connection going
|
|
294
|
+
# back to the pool.
|
|
295
|
+
info = _live_connection_info(conn)
|
|
296
|
+
if info is None:
|
|
297
|
+
return
|
|
298
|
+
started_at = info.pop("sqlite_write_started_at", None)
|
|
299
|
+
first_statement = info.pop("sqlite_first_write_statement", None)
|
|
300
|
+
last_statement = info.pop("sqlite_last_write_statement", None)
|
|
301
|
+
task_name = info.pop("sqlite_write_task", None)
|
|
302
|
+
if started_at is None:
|
|
303
|
+
# A rollback after a commit whose DBAPI call raised: the pending
|
|
304
|
+
# report already holds outcome=commit, but the transaction is in
|
|
305
|
+
# fact ending by rollback — rewrite the outcome so the report does
|
|
306
|
+
# not claim a durable commit that never happened.
|
|
307
|
+
pending = info.get("sqlite_write_pending_report")
|
|
308
|
+
if outcome == "rollback" and isinstance(pending, tuple) and pending[1] == "commit":
|
|
309
|
+
info["sqlite_write_pending_report"] = (pending[0], "commit_failed_rollback", *pending[2:])
|
|
310
|
+
return
|
|
311
|
+
info["sqlite_write_pending_report"] = (started_at, outcome, first_statement, last_statement, task_name)
|
|
312
|
+
|
|
313
|
+
@event.listens_for(engine, "commit")
|
|
314
|
+
def _mark_on_commit(conn: object) -> None:
|
|
315
|
+
_mark_transaction_ending(conn, outcome="commit")
|
|
316
|
+
|
|
317
|
+
@event.listens_for(engine, "rollback")
|
|
318
|
+
def _mark_on_rollback(conn: object) -> None:
|
|
319
|
+
_mark_transaction_ending(conn, outcome="rollback")
|
|
320
|
+
|
|
321
|
+
@event.listens_for(engine, "begin")
|
|
322
|
+
def _finalize_on_next_begin(conn: object) -> None:
|
|
323
|
+
info = _live_connection_info(conn)
|
|
324
|
+
if info is not None:
|
|
325
|
+
_finalize_pending_report(info)
|
|
326
|
+
|
|
327
|
+
@event.listens_for(engine, "checkin")
|
|
328
|
+
def _finalize_on_checkin(dbapi_connection: object, connection_record: object) -> None:
|
|
329
|
+
info = getattr(connection_record, "info", None)
|
|
330
|
+
if info is not None:
|
|
331
|
+
_finalize_pending_report(info)
|
|
332
|
+
|
|
333
|
+
@event.listens_for(engine, "handle_error")
|
|
334
|
+
def _flip_outcome_on_failed_end(exception_context: object) -> None:
|
|
335
|
+
# A DBAPI commit that raises still ends the transaction, but by
|
|
336
|
+
# rollback; the pending report marked at the commit event must not
|
|
337
|
+
# claim a durable commit that never happened.
|
|
338
|
+
connection = getattr(exception_context, "connection", None)
|
|
339
|
+
info = _live_connection_info(connection) if connection is not None else None
|
|
340
|
+
if info is None:
|
|
341
|
+
return
|
|
342
|
+
pending = info.get("sqlite_write_pending_report")
|
|
343
|
+
if isinstance(pending, tuple) and pending[1] == "commit":
|
|
344
|
+
info["sqlite_write_pending_report"] = (pending[0], "commit_failed_rollback", *pending[2:])
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _create_main_engine(url: str) -> AsyncEngine:
|
|
348
|
+
if not _is_sqlite_url(url):
|
|
349
|
+
return _create_postgres_async_engine(
|
|
350
|
+
url,
|
|
351
|
+
role=_PostgresPooledEngineRole.REQUEST_PATH,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
is_sqlite_memory = _is_sqlite_memory_url(url)
|
|
355
|
+
if is_sqlite_memory:
|
|
356
|
+
main_engine = create_async_engine(
|
|
357
|
+
url,
|
|
358
|
+
echo=False,
|
|
359
|
+
connect_args={"timeout": _SQLITE_BUSY_TIMEOUT_SECONDS},
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
main_engine = create_async_engine(
|
|
363
|
+
url,
|
|
364
|
+
echo=False,
|
|
365
|
+
**_sqlite_file_async_engine_kwargs(),
|
|
366
|
+
)
|
|
367
|
+
_configure_sqlite_engine(main_engine.sync_engine, enable_wal=not is_sqlite_memory)
|
|
368
|
+
return main_engine
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
engine = _create_main_engine(_database_url)
|
|
372
|
+
|
|
373
|
+
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
|
374
|
+
|
|
375
|
+
_background_engine: AsyncEngine | None = None
|
|
376
|
+
_background_session_factory: async_sessionmaker[AsyncSession] | None = None
|
|
377
|
+
_sqlite_writer_lock: anyio.Lock | None = None
|
|
378
|
+
_sqlite_lifetime_lock: sqlite3.Connection | None = None
|
|
379
|
+
_sqlite_lifetime_lock_path: Path | None = None
|
|
380
|
+
|
|
381
|
+
_T = TypeVar("_T")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class _SqliteBackupCreator(Protocol):
|
|
385
|
+
def __call__(self, source: Path, *, max_files: int) -> Path: ...
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _ensure_sqlite_dir(url: str) -> None:
|
|
389
|
+
sqlite_path = sqlite_db_path_from_url(url)
|
|
390
|
+
if sqlite_path is None:
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _release_sqlite_lifetime_lock() -> None:
|
|
397
|
+
global _sqlite_lifetime_lock, _sqlite_lifetime_lock_path
|
|
398
|
+
connection = _sqlite_lifetime_lock
|
|
399
|
+
_sqlite_lifetime_lock = None
|
|
400
|
+
_sqlite_lifetime_lock_path = None
|
|
401
|
+
if connection is not None:
|
|
402
|
+
release_sqlite_runstate_lock(connection)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _acquire_sqlite_lifetime_lock(sqlite_path: Path) -> None:
|
|
406
|
+
global _sqlite_lifetime_lock, _sqlite_lifetime_lock_path
|
|
407
|
+
if _sqlite_lifetime_lock is not None and _sqlite_lifetime_lock_path == sqlite_path:
|
|
408
|
+
return
|
|
409
|
+
_release_sqlite_lifetime_lock()
|
|
410
|
+
connection = acquire_sqlite_runstate_lock(sqlite_path)
|
|
411
|
+
_sqlite_lifetime_lock = connection
|
|
412
|
+
_sqlite_lifetime_lock_path = sqlite_path
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _startup_sqlite_check_mode(raw_mode: str) -> SqliteIntegrityCheckMode | None:
|
|
416
|
+
if raw_mode == "off":
|
|
417
|
+
return None
|
|
418
|
+
return SqliteIntegrityCheckMode(raw_mode)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _sqlite_startup_check_required(
|
|
422
|
+
sqlite_path: Path,
|
|
423
|
+
*,
|
|
424
|
+
mode: SqliteIntegrityCheckMode,
|
|
425
|
+
previous_state: SqliteRunState | None,
|
|
426
|
+
running_recorded: bool,
|
|
427
|
+
running_identity: SqliteFileIdentity | None,
|
|
428
|
+
) -> bool:
|
|
429
|
+
"""Decide whether this startup has to scan the whole SQLite file.
|
|
430
|
+
|
|
431
|
+
The scan reads every page, so its cost grows with the store and the
|
|
432
|
+
listener cannot bind until it returns. SQLite is already consistent after
|
|
433
|
+
a clean close, so the scan only earns its cost when the previous process
|
|
434
|
+
did not get to record one. Anything other than a recorded clean shutdown
|
|
435
|
+
(a crash, an OOM kill, a power loss, a first run, or an upgrade from a
|
|
436
|
+
build that never wrote the sidecar) still pays for the scan.
|
|
437
|
+
"""
|
|
438
|
+
# The prior state is captured before startup marks this process RUNNING.
|
|
439
|
+
# A clean sidecar is not evidence for this startup if that transition was
|
|
440
|
+
# not durably recorded.
|
|
441
|
+
if not running_recorded or previous_state is not SqliteRunState.CLEAN:
|
|
442
|
+
return True
|
|
443
|
+
# Revalidate at the decision seam. A recovery replacement can happen
|
|
444
|
+
# between the earlier sidecar reads and this final branch.
|
|
445
|
+
current_identity = _sqlite_file_identity(sqlite_path)
|
|
446
|
+
if running_identity is None or current_identity is None or running_identity != current_identity:
|
|
447
|
+
return True
|
|
448
|
+
logger.info(
|
|
449
|
+
"Skipping SQLite startup %s after a recorded clean shutdown path=%s",
|
|
450
|
+
integrity_check_pragma_name(mode),
|
|
451
|
+
sqlite_path,
|
|
452
|
+
)
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _run_startup_sqlite_check(sqlite_path: Path, *, mode: SqliteIntegrityCheckMode) -> IntegrityCheck:
|
|
457
|
+
"""Run the startup scan, announcing it so the stall is never unexplained."""
|
|
458
|
+
pragma_name = integrity_check_pragma_name(mode)
|
|
459
|
+
try:
|
|
460
|
+
size_bytes = sqlite_path.stat().st_size
|
|
461
|
+
except OSError:
|
|
462
|
+
size_bytes = 0
|
|
463
|
+
logger.info(
|
|
464
|
+
"Running SQLite startup %s path=%s size_bytes=%s; the listener does not bind until it completes",
|
|
465
|
+
pragma_name,
|
|
466
|
+
sqlite_path,
|
|
467
|
+
size_bytes,
|
|
468
|
+
)
|
|
469
|
+
started_monotonic = time.monotonic()
|
|
470
|
+
integrity = check_sqlite_integrity(sqlite_path, mode=mode)
|
|
471
|
+
elapsed_seconds = time.monotonic() - started_monotonic
|
|
472
|
+
if integrity.ok:
|
|
473
|
+
logger.info(
|
|
474
|
+
"SQLite startup %s passed in %.1fs path=%s",
|
|
475
|
+
pragma_name,
|
|
476
|
+
elapsed_seconds,
|
|
477
|
+
sqlite_path,
|
|
478
|
+
)
|
|
479
|
+
return integrity
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _mark_sqlite_running(sqlite_path: Path) -> bool:
|
|
483
|
+
recorded = write_sqlite_runstate(sqlite_path, SqliteRunState.RUNNING)
|
|
484
|
+
if not recorded:
|
|
485
|
+
logger.warning(
|
|
486
|
+
"Failed to record the SQLite run state path=%s; the next startup will re-run the integrity check",
|
|
487
|
+
sqlite_path,
|
|
488
|
+
)
|
|
489
|
+
return recorded
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def mark_sqlite_shutdown_clean() -> None:
|
|
493
|
+
"""Record that this process closed the SQLite store cleanly.
|
|
494
|
+
|
|
495
|
+
Call this after the engines are disposed while the process still owns the
|
|
496
|
+
SQLite lifetime lock. The next startup reads it and skips the integrity
|
|
497
|
+
scan, which is what keeps an operator restart from paying a whole-file read
|
|
498
|
+
that grows with the store.
|
|
499
|
+
"""
|
|
500
|
+
sqlite_path = sqlite_db_path_from_url(normalize_sqlite_url(_settings.database_url))
|
|
501
|
+
if sqlite_path is None:
|
|
502
|
+
return
|
|
503
|
+
if _sqlite_lifetime_lock is None or _sqlite_lifetime_lock_path != sqlite_path:
|
|
504
|
+
logger.warning(
|
|
505
|
+
"Cannot record a clean SQLite shutdown without the lifetime lock path=%s; "
|
|
506
|
+
"the next startup will run the integrity check",
|
|
507
|
+
sqlite_path,
|
|
508
|
+
)
|
|
509
|
+
return
|
|
510
|
+
try:
|
|
511
|
+
if not write_sqlite_runstate(sqlite_path, SqliteRunState.CLEAN):
|
|
512
|
+
logger.warning(
|
|
513
|
+
"Failed to record a clean SQLite shutdown path=%s; the next startup will run the integrity check",
|
|
514
|
+
sqlite_path,
|
|
515
|
+
)
|
|
516
|
+
finally:
|
|
517
|
+
# The clean transition is the last operation that needs ownership. A
|
|
518
|
+
# failed write remains unknown, but must not leave a process lock held
|
|
519
|
+
# after shutdown has completed.
|
|
520
|
+
_release_sqlite_lifetime_lock()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def _shielded(awaitable: Awaitable[object]) -> None:
|
|
524
|
+
cancellation = await _await_cleanup_deferring_cancellation(awaitable)
|
|
525
|
+
if cancellation is not None:
|
|
526
|
+
raise cancellation
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
async def _shielded_bounded(awaitable: Awaitable[object], timeout: float) -> asyncio.Task[object] | None:
|
|
530
|
+
"""Observe ``awaitable`` within a bound, shielding it from caller cancellation.
|
|
531
|
+
|
|
532
|
+
Returns ``None`` on successful completion and re-raises task failures.
|
|
533
|
+
Otherwise returns the pending task without cancelling it. The caller owns
|
|
534
|
+
further completion observation and any reclamation; the elapsed deadline
|
|
535
|
+
alone does not show whether the worker is stuck.
|
|
536
|
+
"""
|
|
537
|
+
task = asyncio.ensure_future(awaitable)
|
|
538
|
+
loop = asyncio.get_running_loop()
|
|
539
|
+
deadline = loop.time() + timeout
|
|
540
|
+
# The anyio shield keeps a level-cancelled scope from re-raising into
|
|
541
|
+
# every ``await`` (busy-spin); ``asyncio.wait`` removes its callback in a
|
|
542
|
+
# ``finally`` and never cancels ``task``, unlike 3.14's ``asyncio.shield``
|
|
543
|
+
# which leaks a done-callback per cancelled wait. The deadline (not a
|
|
544
|
+
# restarted timeout) keeps the bound exact under repeated edge cancels.
|
|
545
|
+
with anyio.CancelScope(shield=True):
|
|
546
|
+
while not task.done():
|
|
547
|
+
remaining = deadline - loop.time()
|
|
548
|
+
if remaining <= 0:
|
|
549
|
+
break
|
|
550
|
+
try:
|
|
551
|
+
await asyncio.wait({task}, timeout=remaining)
|
|
552
|
+
except asyncio.CancelledError:
|
|
553
|
+
# Teardown runs in ``finally`` blocks: the bound, not the
|
|
554
|
+
# caller's cancellation, decides abandonment.
|
|
555
|
+
continue
|
|
556
|
+
if task.done():
|
|
557
|
+
task.result()
|
|
558
|
+
return None
|
|
559
|
+
return task
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _sqlite_uri_mode_active(url: Any) -> bool:
|
|
563
|
+
"""Whether the pysqlite/aiosqlite dialect will connect in URI mode.
|
|
564
|
+
|
|
565
|
+
Mirrors the dialect's ``create_connect_args``: URI mode is enabled only
|
|
566
|
+
when the URL query carries a ``uri`` value that coerces to true.
|
|
567
|
+
"""
|
|
568
|
+
query = getattr(url, "query", None)
|
|
569
|
+
if query is None:
|
|
570
|
+
return False
|
|
571
|
+
try:
|
|
572
|
+
value = query.get("uri")
|
|
573
|
+
except Exception:
|
|
574
|
+
return False
|
|
575
|
+
values = value if isinstance(value, (tuple, list)) else (value,)
|
|
576
|
+
for item in values:
|
|
577
|
+
if item is None:
|
|
578
|
+
continue
|
|
579
|
+
try:
|
|
580
|
+
if sqlalchemy_util.asbool(str(item)):
|
|
581
|
+
return True
|
|
582
|
+
except Exception:
|
|
583
|
+
# An unrecognized value would fail at connect time anyway;
|
|
584
|
+
# classify conservatively as file-backed (bounded).
|
|
585
|
+
continue
|
|
586
|
+
return False
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _session_teardown_bound_seconds(session: AsyncSession) -> float | None:
|
|
590
|
+
"""Teardown deadline for this session, or None for the unbounded path.
|
|
591
|
+
|
|
592
|
+
Only file-backed SQLite gets a bound: its single writer slot turns a
|
|
593
|
+
wedged teardown into a database-wide write stall (issue #1682).
|
|
594
|
+
PostgreSQL teardown semantics are deliberately untouched, and in-memory
|
|
595
|
+
SQLite shares one StaticPool connection with the whole process — the
|
|
596
|
+
reclaim's invalidation would destroy the entire database (the
|
|
597
|
+
database-backends spec requires preserving shared in-memory state), and
|
|
598
|
+
with a single shared connection there is no cross-connection writer
|
|
599
|
+
contention to starve in the first place.
|
|
600
|
+
"""
|
|
601
|
+
try:
|
|
602
|
+
bind = session.get_bind()
|
|
603
|
+
except Exception:
|
|
604
|
+
return None
|
|
605
|
+
if getattr(getattr(bind, "dialect", None), "name", None) != "sqlite":
|
|
606
|
+
return None
|
|
607
|
+
url = getattr(bind, "url", None)
|
|
608
|
+
if url is not None:
|
|
609
|
+
database = getattr(url, "database", None)
|
|
610
|
+
if not database:
|
|
611
|
+
return None
|
|
612
|
+
database_text = str(database)
|
|
613
|
+
if database_text == ":memory:":
|
|
614
|
+
return None
|
|
615
|
+
# SQLite URI forms (``sqlite:///file:name?mode=memory&cache=shared&uri=true``)
|
|
616
|
+
# are in-memory only when the pysqlite/aiosqlite dialect actually
|
|
617
|
+
# passes the database string to the driver as a URI, which it does
|
|
618
|
+
# only when the URL query carries a truthy ``uri`` — and SQLite itself
|
|
619
|
+
# parses a filename as a URI only when it starts with ``file:``.
|
|
620
|
+
# Without ``uri=true``, ``file:name?mode=memory`` is a *file-backed*
|
|
621
|
+
# database whose filename literally contains those characters, so it
|
|
622
|
+
# must keep the bounded teardown.
|
|
623
|
+
if _sqlite_uri_mode_active(url) and database_text.startswith("file:"):
|
|
624
|
+
# ``mode=memory`` normally rides the parsed URL's query; it only
|
|
625
|
+
# appears inside ``url.database`` when the URL escaped the query
|
|
626
|
+
# into the database portion.
|
|
627
|
+
if ":memory:" in database_text or "mode=memory" in database_text:
|
|
628
|
+
return None
|
|
629
|
+
query = getattr(url, "query", None)
|
|
630
|
+
if query is not None:
|
|
631
|
+
try:
|
|
632
|
+
mode = query.get("mode")
|
|
633
|
+
except Exception:
|
|
634
|
+
mode = None
|
|
635
|
+
modes = mode if isinstance(mode, (tuple, list)) else (mode,)
|
|
636
|
+
if any(str(value).lower() == "memory" for value in modes if value is not None):
|
|
637
|
+
return None
|
|
638
|
+
return _SQLITE_TEARDOWN_TIMEOUT_SECONDS
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _session_is_teardown_wedged(session: AsyncSession) -> bool:
|
|
642
|
+
try:
|
|
643
|
+
return bool(session.info.get(_SQLITE_TEARDOWN_WEDGED_INFO_KEY))
|
|
644
|
+
except Exception:
|
|
645
|
+
return False
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _session_sync_connections(session: AsyncSession) -> tuple[Connection, ...]:
|
|
649
|
+
"""Best-effort snapshot of the sync Connections held by the session's transaction.
|
|
650
|
+
|
|
651
|
+
Captured before a teardown attempt so a wedged rollback can be attributed
|
|
652
|
+
and its connection reclaimed. Diagnostics only — never raises.
|
|
653
|
+
"""
|
|
654
|
+
try:
|
|
655
|
+
transaction = session.sync_session.get_transaction()
|
|
656
|
+
if transaction is None:
|
|
657
|
+
return ()
|
|
658
|
+
connections = getattr(transaction, "_connections", None)
|
|
659
|
+
if not isinstance(connections, dict):
|
|
660
|
+
return ()
|
|
661
|
+
# The transaction tracks each Connection under two keys (the
|
|
662
|
+
# Connection itself and its Engine); deduplicate by identity.
|
|
663
|
+
unique: dict[int, Connection] = {}
|
|
664
|
+
for value in connections.values():
|
|
665
|
+
if isinstance(value, tuple) and value and isinstance(value[0], Connection):
|
|
666
|
+
unique[id(value[0])] = value[0]
|
|
667
|
+
return tuple(unique.values())
|
|
668
|
+
except Exception:
|
|
669
|
+
return ()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _sqlite_watchdog_identifiers(connection: Connection) -> str:
|
|
673
|
+
"""Render the long-write watchdog's identifiers for the wedged connection.
|
|
674
|
+
|
|
675
|
+
Invalidation prevents the connection from ever reaching the watchdog's
|
|
676
|
+
deferred report (next begin / pool checkin), so the reclaim log carries
|
|
677
|
+
the same attribution instead.
|
|
678
|
+
"""
|
|
679
|
+
try:
|
|
680
|
+
info = connection.info
|
|
681
|
+
started_at = info.get("sqlite_write_started_at")
|
|
682
|
+
first_statement = info.get("sqlite_first_write_statement")
|
|
683
|
+
last_statement = info.get("sqlite_last_write_statement")
|
|
684
|
+
task_name = info.get("sqlite_write_task")
|
|
685
|
+
if started_at is None:
|
|
686
|
+
# The watchdog's commit/rollback listener already moved the
|
|
687
|
+
# identifiers into the deferred report — the wedge happened inside
|
|
688
|
+
# the transaction-ending call itself, exactly the issue #1682
|
|
689
|
+
# shape.
|
|
690
|
+
pending = info.get("sqlite_write_pending_report")
|
|
691
|
+
if isinstance(pending, tuple) and len(pending) == 5:
|
|
692
|
+
started_at, _, first_statement, last_statement, task_name = pending
|
|
693
|
+
held = f"{time.monotonic() - started_at:.1f}" if isinstance(started_at, float) else "unknown"
|
|
694
|
+
return (
|
|
695
|
+
f"write_held_seconds={held} write_task={task_name!r} "
|
|
696
|
+
f"first_statement={first_statement!r} last_statement={last_statement!r}"
|
|
697
|
+
)
|
|
698
|
+
except Exception:
|
|
699
|
+
return "write_held_seconds=unknown"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
async def _teardown_completed_after_bound(abandoned: asyncio.Task[object]) -> bool:
|
|
703
|
+
"""Observe successful completion during grace before reclaiming the session.
|
|
704
|
+
|
|
705
|
+
A worker can finish while its loop callbacks are delayed. Failure or
|
|
706
|
+
cancellation still requires reclamation using the captured connections:
|
|
707
|
+
SQLAlchemy may clear ``session._transaction`` before releasing them all.
|
|
708
|
+
"""
|
|
709
|
+
try:
|
|
710
|
+
pending = await _shielded_bounded(abandoned, _SQLITE_TEARDOWN_COMPLETION_GRACE_SECONDS)
|
|
711
|
+
except (Exception, asyncio.CancelledError):
|
|
712
|
+
return False
|
|
713
|
+
return pending is None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
async def _reclaim_wedged_sqlite_session(
|
|
717
|
+
session: AsyncSession,
|
|
718
|
+
abandoned: asyncio.Task[object],
|
|
719
|
+
connections: tuple[Connection, ...],
|
|
720
|
+
*,
|
|
721
|
+
phase: str,
|
|
722
|
+
elapsed_seconds: float,
|
|
723
|
+
) -> None:
|
|
724
|
+
"""Fence the session and attempt to reclaim its captured open connections.
|
|
725
|
+
|
|
726
|
+
Interrupting the driver and invalidating the connection can release a
|
|
727
|
+
blocked SQLite writer. Each attempt can fail, so retain ownership of the
|
|
728
|
+
original teardown and report cleanup failures without inferring a
|
|
729
|
+
permanent lock from an exception.
|
|
730
|
+
"""
|
|
731
|
+
try:
|
|
732
|
+
session.info[_SQLITE_TEARDOWN_WEDGED_INFO_KEY] = True
|
|
733
|
+
except Exception:
|
|
734
|
+
logger.exception("Failed to fence a wedged SQLite session during teardown reclaim")
|
|
735
|
+
# Own the abandoned teardown before this coroutine's first await: if
|
|
736
|
+
# close_db runs concurrently with the reclaim, it must already see the
|
|
737
|
+
# pending task in the registry instead of returning while the rollback is
|
|
738
|
+
# still pending. The completion callbacks are attached only after the
|
|
739
|
+
# reclamation attempts below, so the deferred bookkeeping close follows
|
|
740
|
+
# those attempts and completion of the original teardown.
|
|
741
|
+
_wedged_teardown_cleanup_tasks.add(abandoned)
|
|
742
|
+
for connection in connections:
|
|
743
|
+
if connection.closed:
|
|
744
|
+
# The teardown that outlived the bound got far enough to close this
|
|
745
|
+
# connection before it failed or was cancelled inside grace.
|
|
746
|
+
# Skip the closed handle, but still report the teardown failure
|
|
747
|
+
# that ``_finish_abandoned_teardown`` otherwise consumes.
|
|
748
|
+
failure = abandoned.exception() if abandoned.done() and not abandoned.cancelled() else None
|
|
749
|
+
if failure is not None:
|
|
750
|
+
logger.warning(
|
|
751
|
+
"sqlite_wedged_teardown phase=%s bound_seconds=%.1f elapsed_seconds=%.1f; the %s failed "
|
|
752
|
+
"after releasing its connection; skipping the closed handle: %r",
|
|
753
|
+
phase,
|
|
754
|
+
_SQLITE_TEARDOWN_TIMEOUT_SECONDS,
|
|
755
|
+
elapsed_seconds,
|
|
756
|
+
phase,
|
|
757
|
+
failure,
|
|
758
|
+
)
|
|
759
|
+
else:
|
|
760
|
+
logger.debug(
|
|
761
|
+
"sqlite_wedged_teardown phase=%s; the abandoned %s already closed its connection; "
|
|
762
|
+
"nothing to reclaim",
|
|
763
|
+
phase,
|
|
764
|
+
phase,
|
|
765
|
+
)
|
|
766
|
+
continue
|
|
767
|
+
logger.warning(
|
|
768
|
+
"sqlite_wedged_teardown phase=%s bound_seconds=%.1f elapsed_seconds=%.1f %s; attempting "
|
|
769
|
+
"connection interruption and invalidation to release the writer slot (issue #1682)",
|
|
770
|
+
phase,
|
|
771
|
+
_SQLITE_TEARDOWN_TIMEOUT_SECONDS,
|
|
772
|
+
elapsed_seconds,
|
|
773
|
+
_sqlite_watchdog_identifiers(connection),
|
|
774
|
+
)
|
|
775
|
+
try:
|
|
776
|
+
driver = connection.connection.driver_connection
|
|
777
|
+
if driver is not None:
|
|
778
|
+
# aiosqlite's ``interrupt`` runs sqlite3_interrupt inline on
|
|
779
|
+
# this task — it never enters the (wedged) worker queue. In
|
|
780
|
+
# the pinned aiosqlite (0.22.x) it is a coroutine function;
|
|
781
|
+
# await the result only when it is awaitable so a driver that
|
|
782
|
+
# makes ``interrupt`` synchronous keeps working.
|
|
783
|
+
result = driver.interrupt()
|
|
784
|
+
if inspect.isawaitable(result):
|
|
785
|
+
await result
|
|
786
|
+
except Exception:
|
|
787
|
+
logger.warning(
|
|
788
|
+
"Interrupting a wedged SQLite connection failed; connection invalidation will still be attempted",
|
|
789
|
+
exc_info=True,
|
|
790
|
+
)
|
|
791
|
+
try:
|
|
792
|
+
connection.invalidate()
|
|
793
|
+
except Exception:
|
|
794
|
+
# Report failed reclamation even though the owned teardown may
|
|
795
|
+
# still release the connection later.
|
|
796
|
+
logger.warning(
|
|
797
|
+
"Invalidating a wedged SQLite connection failed; writer-slot release is unconfirmed (issue #1981)",
|
|
798
|
+
exc_info=True,
|
|
799
|
+
)
|
|
800
|
+
if not connections:
|
|
801
|
+
logger.warning(
|
|
802
|
+
"sqlite_wedged_teardown phase=%s bound_seconds=%.1f elapsed_seconds=%.1f; no captured connection "
|
|
803
|
+
"to reclaim; tracking the abandoned %s (issue #1682)",
|
|
804
|
+
phase,
|
|
805
|
+
_SQLITE_TEARDOWN_TIMEOUT_SECONDS,
|
|
806
|
+
elapsed_seconds,
|
|
807
|
+
phase,
|
|
808
|
+
)
|
|
809
|
+
# The abandoned teardown is owned until completion (registered above, so
|
|
810
|
+
# close_db drains it and shutdown waits for — or boundedly abandons — the
|
|
811
|
+
# reclaimed rollback/close instead of returning while it is still
|
|
812
|
+
# pending). The discard callback is registered first so that when the task
|
|
813
|
+
# completes during the drain, deregistration happens before
|
|
814
|
+
# _finish_abandoned_teardown registers the follow-up bookkeeping close.
|
|
815
|
+
abandoned.add_done_callback(_wedged_teardown_cleanup_tasks.discard)
|
|
816
|
+
abandoned.add_done_callback(lambda task: _finish_abandoned_teardown(session, task, phase=phase))
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _finish_abandoned_teardown(session: AsyncSession, task: asyncio.Task[object], *, phase: str) -> None:
|
|
820
|
+
if not task.cancelled():
|
|
821
|
+
# The wedged teardown resuming into an interrupted/invalidated
|
|
822
|
+
# connection is expected to error; consume it so the abandoned task
|
|
823
|
+
# never logs "exception was never retrieved".
|
|
824
|
+
task.exception()
|
|
825
|
+
logger.info("SQLite teardown task finished phase=%s", phase)
|
|
826
|
+
if phase != "rollback":
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
# The session was abandoned before ``close`` ran. Now that no other
|
|
830
|
+
# coroutine can be driving it, finish closing it for bookkeeping and any
|
|
831
|
+
# connection cleanup still needed after the reclamation attempts.
|
|
832
|
+
async def _close_late() -> None:
|
|
833
|
+
try:
|
|
834
|
+
await asyncio.wait_for(session.close(), timeout=_SQLITE_TEARDOWN_TIMEOUT_SECONDS)
|
|
835
|
+
except BaseException:
|
|
836
|
+
logger.debug("Late close of a wedged SQLite session failed", exc_info=True)
|
|
837
|
+
|
|
838
|
+
try:
|
|
839
|
+
cleanup_task = asyncio.get_running_loop().create_task(_close_late())
|
|
840
|
+
except RuntimeError:
|
|
841
|
+
# Event loop already gone; no follow-up task can be scheduled.
|
|
842
|
+
return
|
|
843
|
+
# Own the task until completion: close_db drains it so shutdown cannot
|
|
844
|
+
# skip the promised bookkeeping close or leave a pending-task warning.
|
|
845
|
+
_wedged_teardown_cleanup_tasks.add(cleanup_task)
|
|
846
|
+
cleanup_task.add_done_callback(_wedged_teardown_cleanup_tasks.discard)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
async def _finish_bounded_teardown(
|
|
850
|
+
session: AsyncSession,
|
|
851
|
+
abandoned: asyncio.Task[object],
|
|
852
|
+
held_connections: tuple[Connection, ...],
|
|
853
|
+
*,
|
|
854
|
+
phase: str,
|
|
855
|
+
elapsed_seconds: float,
|
|
856
|
+
) -> None:
|
|
857
|
+
"""Observe successful completion in grace or retain reclamation ownership."""
|
|
858
|
+
if await _teardown_completed_after_bound(abandoned):
|
|
859
|
+
logger.warning(
|
|
860
|
+
"sqlite_teardown_bound_elapsed_but_completed phase=%s bound_seconds=%.1f elapsed_seconds=%.1f; "
|
|
861
|
+
"%s completed successfully during completion grace; skipping reclamation (issue #2029)",
|
|
862
|
+
phase,
|
|
863
|
+
_SQLITE_TEARDOWN_TIMEOUT_SECONDS,
|
|
864
|
+
elapsed_seconds,
|
|
865
|
+
phase,
|
|
866
|
+
)
|
|
867
|
+
return
|
|
868
|
+
await _reclaim_wedged_sqlite_session(
|
|
869
|
+
session,
|
|
870
|
+
abandoned,
|
|
871
|
+
held_connections,
|
|
872
|
+
phase=phase,
|
|
873
|
+
elapsed_seconds=elapsed_seconds,
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
async def _safe_rollback(session: AsyncSession) -> None:
|
|
878
|
+
if not session.in_transaction():
|
|
879
|
+
return
|
|
880
|
+
if _session_is_teardown_wedged(session):
|
|
881
|
+
# A previous bounded teardown abandoned a wedged rollback; the
|
|
882
|
+
# abandoned greenlet may still resume, so never drive this session
|
|
883
|
+
# concurrently. The tracked cleanup now owns the connection.
|
|
884
|
+
return
|
|
885
|
+
bound = _session_teardown_bound_seconds(session)
|
|
886
|
+
if bound is None:
|
|
887
|
+
try:
|
|
888
|
+
await _shielded(session.rollback())
|
|
889
|
+
except BaseException:
|
|
890
|
+
return
|
|
891
|
+
return
|
|
892
|
+
held_connections = _session_sync_connections(session)
|
|
893
|
+
started = asyncio.get_running_loop().time()
|
|
894
|
+
try:
|
|
895
|
+
abandoned = await _shielded_bounded(session.rollback(), bound)
|
|
896
|
+
except BaseException:
|
|
897
|
+
return
|
|
898
|
+
if abandoned is not None:
|
|
899
|
+
await _finish_bounded_teardown(
|
|
900
|
+
session,
|
|
901
|
+
abandoned,
|
|
902
|
+
held_connections,
|
|
903
|
+
phase="rollback",
|
|
904
|
+
elapsed_seconds=asyncio.get_running_loop().time() - started,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
async def _safe_close(session: AsyncSession) -> None:
|
|
909
|
+
if _session_is_teardown_wedged(session):
|
|
910
|
+
# Deferred cleanup owns the session now; see _finish_abandoned_teardown.
|
|
911
|
+
return
|
|
912
|
+
bound = _session_teardown_bound_seconds(session)
|
|
913
|
+
if bound is None:
|
|
914
|
+
try:
|
|
915
|
+
await _shielded(session.close())
|
|
916
|
+
except BaseException:
|
|
917
|
+
return
|
|
918
|
+
return
|
|
919
|
+
held_connections = _session_sync_connections(session)
|
|
920
|
+
started = asyncio.get_running_loop().time()
|
|
921
|
+
try:
|
|
922
|
+
abandoned = await _shielded_bounded(session.close(), bound)
|
|
923
|
+
except BaseException:
|
|
924
|
+
return
|
|
925
|
+
if abandoned is not None:
|
|
926
|
+
await _finish_bounded_teardown(
|
|
927
|
+
session,
|
|
928
|
+
abandoned,
|
|
929
|
+
held_connections,
|
|
930
|
+
phase="close",
|
|
931
|
+
elapsed_seconds=asyncio.get_running_loop().time() - started,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
async def close_session(session: AsyncSession) -> None:
|
|
936
|
+
async def _close() -> None:
|
|
937
|
+
if session.in_transaction():
|
|
938
|
+
await _safe_rollback(session)
|
|
939
|
+
await _safe_close(session)
|
|
940
|
+
|
|
941
|
+
await _shielded(_close())
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def detach_session_objects(session: AsyncSession) -> None:
|
|
945
|
+
"""Detach loaded ORM rows that must outlive this session boundary."""
|
|
946
|
+
session.expunge_all()
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _load_migration_entrypoints() -> tuple[
|
|
950
|
+
Callable[[str], "MigrationState"],
|
|
951
|
+
Callable[[str], Awaitable["MigrationRunResult"]],
|
|
952
|
+
Callable[[str], tuple[str, ...]],
|
|
953
|
+
]:
|
|
954
|
+
from app.db.migrate import check_schema_drift, inspect_migration_state, run_startup_migrations
|
|
955
|
+
|
|
956
|
+
return inspect_migration_state, run_startup_migrations, check_schema_drift
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _load_sqlite_backup_creator() -> _SqliteBackupCreator:
|
|
960
|
+
from app.db.backup import create_sqlite_pre_migration_backup
|
|
961
|
+
|
|
962
|
+
return create_sqlite_pre_migration_backup
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def init_background_db(url: str | None = None) -> None:
|
|
966
|
+
"""Initialize a separate DB engine for background tasks.
|
|
967
|
+
|
|
968
|
+
The background engine isolates background-task checkouts from the request
|
|
969
|
+
pool; its pool sizing always derives from ``database_pool_size`` /
|
|
970
|
+
``database_max_overflow``.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
url: Database URL. If None, uses settings.database_url.
|
|
974
|
+
"""
|
|
975
|
+
global _background_engine, _background_session_factory
|
|
976
|
+
db_url = normalize_sqlite_url(url or _settings.database_url)
|
|
977
|
+
|
|
978
|
+
if _is_sqlite_url(db_url):
|
|
979
|
+
is_sqlite_memory = _is_sqlite_memory_url(db_url)
|
|
980
|
+
if is_sqlite_memory:
|
|
981
|
+
# Reuse the main engine for in-memory SQLite — creating a second
|
|
982
|
+
# engine would open a separate, empty in-memory database with no
|
|
983
|
+
# schema, causing "no such table" errors in background tasks.
|
|
984
|
+
_background_engine = engine
|
|
985
|
+
_background_session_factory = SessionLocal
|
|
986
|
+
return
|
|
987
|
+
_background_engine = create_async_engine(
|
|
988
|
+
db_url,
|
|
989
|
+
echo=False,
|
|
990
|
+
**_sqlite_file_async_engine_kwargs(),
|
|
991
|
+
)
|
|
992
|
+
_configure_sqlite_engine(_background_engine.sync_engine, enable_wal=not is_sqlite_memory)
|
|
993
|
+
else:
|
|
994
|
+
_background_engine = _create_postgres_async_engine(
|
|
995
|
+
db_url,
|
|
996
|
+
role=_PostgresPooledEngineRole.BACKGROUND_TASK,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
_background_session_factory = async_sessionmaker(_background_engine, expire_on_commit=False, class_=AsyncSession)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
@asynccontextmanager
|
|
1003
|
+
async def get_background_session() -> AsyncIterator[AsyncSession]:
|
|
1004
|
+
"""Session provider for background tasks, schedulers, and auth dependencies.
|
|
1005
|
+
|
|
1006
|
+
Uses the separate background pool if initialized, otherwise falls back to main pool.
|
|
1007
|
+
"""
|
|
1008
|
+
factory = _background_session_factory or SessionLocal
|
|
1009
|
+
session = factory()
|
|
1010
|
+
try:
|
|
1011
|
+
yield session
|
|
1012
|
+
except BaseException:
|
|
1013
|
+
await _safe_rollback(session)
|
|
1014
|
+
raise
|
|
1015
|
+
finally:
|
|
1016
|
+
await close_session(session)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
async def relax_commit_durability(session: AsyncSession) -> None:
|
|
1020
|
+
"""Relax commit durability for the current telemetry write transaction.
|
|
1021
|
+
|
|
1022
|
+
High-frequency append-only telemetry writes (request-log inserts, usage
|
|
1023
|
+
history appends) dominate the slow-query profile on PostgreSQL because
|
|
1024
|
+
every commit waits for a WAL fsync. Those rows are pure observability:
|
|
1025
|
+
losing the final unflushed WAL window (bounded by three times
|
|
1026
|
+
``wal_writer_delay`` — up to ~600 ms at the default 200 ms setting) keeps
|
|
1027
|
+
accounting semantics identical. For such transactions this helper emits
|
|
1028
|
+
``SET LOCAL synchronous_commit = off`` so the commit returns without
|
|
1029
|
+
waiting for the WAL flush.
|
|
1030
|
+
|
|
1031
|
+
``SET LOCAL`` is transaction-scoped: PostgreSQL reverts it automatically
|
|
1032
|
+
at COMMIT/ROLLBACK, so nothing leaks onto the pooled connection. Executed
|
|
1033
|
+
outside a transaction, PostgreSQL merely emits a WARNING and the setting
|
|
1034
|
+
never applies — calling this through an ``AsyncSession`` is what makes it
|
|
1035
|
+
safe, because session autobegin opens the write transaction at this very
|
|
1036
|
+
statement when none is open yet.
|
|
1037
|
+
|
|
1038
|
+
No-op on SQLite (durability there is governed by ``PRAGMA synchronous``).
|
|
1039
|
+
MUST NOT be used for configuration writes (accounts, API keys, settings,
|
|
1040
|
+
limits management) or for API-key usage-reservation accounting (creation,
|
|
1041
|
+
settlement, stale release): on external/HA PostgreSQL a server failover
|
|
1042
|
+
does not kill in-flight application requests, so an acked-but-lost
|
|
1043
|
+
reservation commit would desynchronize the accounting ledger from
|
|
1044
|
+
requests that still complete. Those paths keep full durability.
|
|
1045
|
+
"""
|
|
1046
|
+
bind = session.get_bind()
|
|
1047
|
+
if bind is None or bind.dialect.name != "postgresql":
|
|
1048
|
+
return
|
|
1049
|
+
await session.execute(text("SET LOCAL synchronous_commit = off"))
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
@asynccontextmanager
|
|
1053
|
+
async def sqlite_writer_section() -> AsyncIterator[None]:
|
|
1054
|
+
"""Serialize local SQLite write transactions without throttling upstream work."""
|
|
1055
|
+
global _sqlite_writer_lock
|
|
1056
|
+
database_url = normalize_sqlite_url(_settings.database_url)
|
|
1057
|
+
if not _is_sqlite_url(database_url) or _is_sqlite_memory_url(database_url):
|
|
1058
|
+
yield
|
|
1059
|
+
return
|
|
1060
|
+
if _sqlite_writer_lock is None:
|
|
1061
|
+
_sqlite_writer_lock = anyio.Lock()
|
|
1062
|
+
async with _sqlite_writer_lock:
|
|
1063
|
+
yield
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
async def get_session() -> AsyncIterator[AsyncSession]:
|
|
1067
|
+
session = SessionLocal()
|
|
1068
|
+
try:
|
|
1069
|
+
yield session
|
|
1070
|
+
except BaseException:
|
|
1071
|
+
await _safe_rollback(session)
|
|
1072
|
+
raise
|
|
1073
|
+
finally:
|
|
1074
|
+
await close_session(session)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
async def _init_db() -> None:
|
|
1078
|
+
database_url = normalize_sqlite_url(_settings.database_url)
|
|
1079
|
+
_ensure_sqlite_dir(database_url)
|
|
1080
|
+
sqlite_path = sqlite_db_path_from_url(database_url)
|
|
1081
|
+
if sqlite_path is not None:
|
|
1082
|
+
# Read the prior transition first, then fence this process as RUNNING
|
|
1083
|
+
# before deciding whether a prior CLEAN record permits skipping the
|
|
1084
|
+
# startup scan. A failed startup therefore leaves RUNNING where the
|
|
1085
|
+
# sidecar can be written instead of resurrecting stale CLEAN state.
|
|
1086
|
+
previous_record = read_sqlite_runstate_record(sqlite_path)
|
|
1087
|
+
previous_state = previous_record.state if previous_record is not None else None
|
|
1088
|
+
previous_clean_identity = (
|
|
1089
|
+
previous_record.identity
|
|
1090
|
+
if previous_record is not None and previous_record.state is SqliteRunState.CLEAN
|
|
1091
|
+
else None
|
|
1092
|
+
)
|
|
1093
|
+
running_transition_error: SqliteRunStateDurabilityError | None = None
|
|
1094
|
+
try:
|
|
1095
|
+
running_recorded = _mark_sqlite_running(sqlite_path)
|
|
1096
|
+
except SqliteRunStateDurabilityError as exc:
|
|
1097
|
+
# A failed write can safely continue only when its old marker was
|
|
1098
|
+
# durably removed. If that invalidation cannot be proven, still
|
|
1099
|
+
# run the configured check, then stop before migrations or serving
|
|
1100
|
+
# instead of allowing a stale CLEAN record to influence startup.
|
|
1101
|
+
running_recorded = False
|
|
1102
|
+
running_transition_error = exc
|
|
1103
|
+
logger.error(
|
|
1104
|
+
"Could not durably invalidate the SQLite run state path=%s; startup will fail closed",
|
|
1105
|
+
sqlite_path,
|
|
1106
|
+
exc_info=exc,
|
|
1107
|
+
)
|
|
1108
|
+
running_record = read_sqlite_runstate_record(sqlite_path) if running_recorded else None
|
|
1109
|
+
running_identity = running_record.identity if running_record is not None else None
|
|
1110
|
+
current_identity = _sqlite_file_identity(sqlite_path)
|
|
1111
|
+
if previous_state is SqliteRunState.CLEAN and (
|
|
1112
|
+
previous_clean_identity is None
|
|
1113
|
+
or running_identity is None
|
|
1114
|
+
or current_identity is None
|
|
1115
|
+
or previous_clean_identity != running_identity
|
|
1116
|
+
or running_identity != current_identity
|
|
1117
|
+
):
|
|
1118
|
+
# The database may have been replaced on either side of the
|
|
1119
|
+
# running fence. The prior clean identity no longer describes the
|
|
1120
|
+
# file that this startup is about to use, so force the scan.
|
|
1121
|
+
previous_state = None
|
|
1122
|
+
check_mode = _startup_sqlite_check_mode(_settings.database_sqlite_startup_check_mode)
|
|
1123
|
+
if check_mode is not None and _sqlite_startup_check_required(
|
|
1124
|
+
sqlite_path,
|
|
1125
|
+
mode=check_mode,
|
|
1126
|
+
previous_state=previous_state,
|
|
1127
|
+
running_recorded=running_recorded,
|
|
1128
|
+
running_identity=running_identity,
|
|
1129
|
+
):
|
|
1130
|
+
integrity = _run_startup_sqlite_check(sqlite_path, mode=check_mode)
|
|
1131
|
+
if not integrity.ok:
|
|
1132
|
+
details = integrity.details or "unknown error"
|
|
1133
|
+
pragma_name = integrity_check_pragma_name(check_mode)
|
|
1134
|
+
logger.error(
|
|
1135
|
+
"SQLite %s failed path=%s details=%s",
|
|
1136
|
+
pragma_name,
|
|
1137
|
+
sqlite_path,
|
|
1138
|
+
details,
|
|
1139
|
+
)
|
|
1140
|
+
if "locked" in details.lower():
|
|
1141
|
+
message = (
|
|
1142
|
+
f"SQLite {pragma_name} failed for {sqlite_path} ({details}). "
|
|
1143
|
+
"Another instance may be running. Stop it and retry."
|
|
1144
|
+
)
|
|
1145
|
+
else:
|
|
1146
|
+
message = (
|
|
1147
|
+
f"SQLite {pragma_name} failed for {sqlite_path} ({details}). "
|
|
1148
|
+
"The database appears corrupted or the filesystem is unhealthy. "
|
|
1149
|
+
"Stop the app and run "
|
|
1150
|
+
f'`python -m app.db.recover --db "{sqlite_path}" --replace` '
|
|
1151
|
+
"or restore a backup from the same directory."
|
|
1152
|
+
)
|
|
1153
|
+
raise RuntimeError(message)
|
|
1154
|
+
if running_transition_error is not None:
|
|
1155
|
+
raise running_transition_error
|
|
1156
|
+
try:
|
|
1157
|
+
inspect_migration_state, run_startup_migrations, check_schema_drift = _load_migration_entrypoints()
|
|
1158
|
+
except ModuleNotFoundError as exc:
|
|
1159
|
+
if exc.name != "app.db.migrate":
|
|
1160
|
+
raise
|
|
1161
|
+
logger.exception("Failed to import migration entrypoint module=app.db.migrate")
|
|
1162
|
+
raise RuntimeError("Database migration entrypoint app.db.migrate is unavailable") from exc
|
|
1163
|
+
except ImportError as exc:
|
|
1164
|
+
logger.exception("Failed to import database migration entrypoints from app.db.migrate")
|
|
1165
|
+
raise RuntimeError("Database migration entrypoint app.db.migrate is invalid") from exc
|
|
1166
|
+
|
|
1167
|
+
if not _settings.database_migrate_on_startup:
|
|
1168
|
+
migration_state = await to_thread.run_sync(
|
|
1169
|
+
lambda: inspect_migration_state(database_url),
|
|
1170
|
+
)
|
|
1171
|
+
if migration_state.needs_upgrade:
|
|
1172
|
+
current_revision = migration_state.current_revision or "none"
|
|
1173
|
+
if migration_state.is_ahead:
|
|
1174
|
+
unknown_revisions = ",".join(migration_state.unknown_revisions)
|
|
1175
|
+
message = (
|
|
1176
|
+
"Startup database migration is disabled and database schema revision(s) "
|
|
1177
|
+
f"{unknown_revisions} are not known to this build (head={migration_state.head_revision}). "
|
|
1178
|
+
"The schema was likely migrated by a newer version; deploy a matching or newer image, "
|
|
1179
|
+
"or run an Alembic downgrade to a revision this build knows."
|
|
1180
|
+
)
|
|
1181
|
+
else:
|
|
1182
|
+
message = (
|
|
1183
|
+
"Startup database migration is disabled but database schema is behind Alembic head "
|
|
1184
|
+
f"(current={current_revision}, head={migration_state.head_revision}). "
|
|
1185
|
+
"Run the dedicated migration job or `python -m app.db.migrate upgrade` before starting the app."
|
|
1186
|
+
)
|
|
1187
|
+
logger.error(message)
|
|
1188
|
+
raise RuntimeError(message)
|
|
1189
|
+
|
|
1190
|
+
logger.info("Startup database migration is disabled and database schema is current")
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
if sqlite_path is not None and _settings.database_sqlite_pre_migrate_backup_enabled and sqlite_path.exists():
|
|
1194
|
+
migration_state = await to_thread.run_sync(
|
|
1195
|
+
lambda: inspect_migration_state(database_url),
|
|
1196
|
+
)
|
|
1197
|
+
if migration_state.needs_upgrade:
|
|
1198
|
+
try:
|
|
1199
|
+
create_sqlite_pre_migration_backup = _load_sqlite_backup_creator()
|
|
1200
|
+
except ModuleNotFoundError as exc:
|
|
1201
|
+
if exc.name != "app.db.backup":
|
|
1202
|
+
raise
|
|
1203
|
+
logger.exception("Failed to import SQLite backup module=app.db.backup")
|
|
1204
|
+
raise RuntimeError("SQLite backup module app.db.backup is unavailable") from exc
|
|
1205
|
+
|
|
1206
|
+
backup_path = await to_thread.run_sync(
|
|
1207
|
+
lambda: create_sqlite_pre_migration_backup(
|
|
1208
|
+
sqlite_path,
|
|
1209
|
+
max_files=_settings.database_sqlite_pre_migrate_backup_max_files,
|
|
1210
|
+
),
|
|
1211
|
+
)
|
|
1212
|
+
logger.info(
|
|
1213
|
+
"Created SQLite pre-migration backup path=%s target_revision=%s",
|
|
1214
|
+
backup_path,
|
|
1215
|
+
migration_state.head_revision,
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
try:
|
|
1219
|
+
result = await run_startup_migrations(database_url)
|
|
1220
|
+
if result.bootstrap.stamped_revision is not None:
|
|
1221
|
+
logger.info(
|
|
1222
|
+
"Bootstrapped legacy migrations stamped_revision=%s legacy_rows=%s",
|
|
1223
|
+
result.bootstrap.stamped_revision,
|
|
1224
|
+
result.bootstrap.legacy_row_count,
|
|
1225
|
+
)
|
|
1226
|
+
if result.current_revision is not None:
|
|
1227
|
+
logger.info("Database migration complete revision=%s", result.current_revision)
|
|
1228
|
+
drift = await to_thread.run_sync(lambda: check_schema_drift(database_url))
|
|
1229
|
+
if drift:
|
|
1230
|
+
drift_details = "; ".join(drift)
|
|
1231
|
+
raise RuntimeError(f"Schema drift detected after startup migrations: {drift_details}")
|
|
1232
|
+
except Exception:
|
|
1233
|
+
logger.exception("Failed to apply database migrations")
|
|
1234
|
+
if _settings.database_migrations_fail_fast:
|
|
1235
|
+
raise
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
async def init_db() -> None:
|
|
1239
|
+
"""Initialize the database while fencing one process onto file SQLite."""
|
|
1240
|
+
database_url = normalize_sqlite_url(_settings.database_url)
|
|
1241
|
+
_ensure_sqlite_dir(database_url)
|
|
1242
|
+
sqlite_path = sqlite_db_path_from_url(database_url)
|
|
1243
|
+
if sqlite_path is None:
|
|
1244
|
+
_release_sqlite_lifetime_lock()
|
|
1245
|
+
await _init_db()
|
|
1246
|
+
return
|
|
1247
|
+
|
|
1248
|
+
_acquire_sqlite_lifetime_lock(sqlite_path)
|
|
1249
|
+
try:
|
|
1250
|
+
await _init_db()
|
|
1251
|
+
except BaseException:
|
|
1252
|
+
# A failed startup never owns a live application, so it must not leave
|
|
1253
|
+
# this process's test/reload path holding the sentinel indefinitely.
|
|
1254
|
+
_release_sqlite_lifetime_lock()
|
|
1255
|
+
raise
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
async def close_db() -> bool:
|
|
1259
|
+
"""Dispose database engines and report whether SQLite teardown fully drained."""
|
|
1260
|
+
loop = asyncio.get_running_loop()
|
|
1261
|
+
# A teardown reclaimed as wedged can finish while engine disposal is in
|
|
1262
|
+
# progress and register its bookkeeping close from a done callback. Use
|
|
1263
|
+
# one deadline for both registry snapshots so that late work is included
|
|
1264
|
+
# without allowing shutdown to grow an unbounded second wait.
|
|
1265
|
+
deadline = loop.time() + 2 * _SQLITE_TEARDOWN_TIMEOUT_SECONDS
|
|
1266
|
+
sqlite_teardown_drained = True
|
|
1267
|
+
|
|
1268
|
+
async def _drain_wedged_teardown_registry() -> None:
|
|
1269
|
+
nonlocal sqlite_teardown_drained
|
|
1270
|
+
while _wedged_teardown_cleanup_tasks:
|
|
1271
|
+
remaining = deadline - loop.time()
|
|
1272
|
+
if remaining <= 0:
|
|
1273
|
+
if sqlite_teardown_drained:
|
|
1274
|
+
logger.warning(
|
|
1275
|
+
"close_db abandoned %d still-pending wedged-teardown task(s) after the bounded "
|
|
1276
|
+
"drain; their connections were already reclaimed (issue #1682)",
|
|
1277
|
+
len(_wedged_teardown_cleanup_tasks),
|
|
1278
|
+
)
|
|
1279
|
+
sqlite_teardown_drained = False
|
|
1280
|
+
return
|
|
1281
|
+
await asyncio.wait(tuple(_wedged_teardown_cleanup_tasks), timeout=remaining)
|
|
1282
|
+
# Completion callbacks (deregistration and scheduling of the
|
|
1283
|
+
# deferred bookkeeping close) run via call_soon; yield once so
|
|
1284
|
+
# the registry reflects them before the next stability check.
|
|
1285
|
+
await asyncio.sleep(0)
|
|
1286
|
+
|
|
1287
|
+
# Drain work already registered before disposal, then give disposal a
|
|
1288
|
+
# chance to schedule late cleanup before taking the final bounded snapshot.
|
|
1289
|
+
await _drain_wedged_teardown_registry()
|
|
1290
|
+
await engine.dispose()
|
|
1291
|
+
if _background_engine is not None:
|
|
1292
|
+
await _background_engine.dispose()
|
|
1293
|
+
await asyncio.sleep(0)
|
|
1294
|
+
await _drain_wedged_teardown_registry()
|
|
1295
|
+
return sqlite_teardown_drained
|