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
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import Float, Result, bindparam, delete, text
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from app.core.config.settings import get_settings
|
|
13
|
+
from app.db.models import SchedulerLeader
|
|
14
|
+
from app.db.session import get_background_session
|
|
15
|
+
from app.db.sqlite_lock_retry import is_sqlite_lock_error, sqlite_error_name
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_T = TypeVar("_T")
|
|
20
|
+
|
|
21
|
+
_MAX_CONSECUTIVE_RENEW_ERRORS = 2
|
|
22
|
+
|
|
23
|
+
# How long a lease-loss (or shutdown) cancellation waits for the gated body to
|
|
24
|
+
# actually finish before detaching it. Bodies may legitimately shield in-flight
|
|
25
|
+
# singleton work (token/usage refresh singleflights) and drain it after
|
|
26
|
+
# cancellation; awaiting that unboundedly would pin ``run_if_leader`` for the
|
|
27
|
+
# duration of an upstream call after the lease is already gone.
|
|
28
|
+
_CANCEL_GRACE_SECONDS = 5.0
|
|
29
|
+
|
|
30
|
+
# How long ``release`` waits for previously detached gated bodies to finish
|
|
31
|
+
# before deleting the lease row. If a detached body is still draining after
|
|
32
|
+
# this grace the early release is skipped entirely: handing the lease to a
|
|
33
|
+
# follower while the old body may still act as leader would recreate the
|
|
34
|
+
# duplicate-singleton overlap, so the lease is left to expire after its TTL.
|
|
35
|
+
_RELEASE_DRAIN_GRACE_SECONDS = 5.0
|
|
36
|
+
|
|
37
|
+
# PostgreSQL evaluates the lease clock server-side so inter-replica wall-clock
|
|
38
|
+
# skew cannot steal a live lease. The stored expiry uses ``clock_timestamp()``
|
|
39
|
+
# (the actual statement-execution time) rather than ``now()`` /
|
|
40
|
+
# ``transaction_timestamp()`` (fixed at transaction start): a renewal or
|
|
41
|
+
# same-leader re-acquire that blocks on the ``scheduler_leader`` row lock must
|
|
42
|
+
# extend from the CURRENT time, not from a timestamp captured before it waited.
|
|
43
|
+
# Because the row lock serializes writers, ``clock_timestamp()`` is evaluated in
|
|
44
|
+
# commit order, so a slow writer that committed after a newer one can never
|
|
45
|
+
# write an earlier ``expires_at`` — the lease can only move forward. The
|
|
46
|
+
# conflict-update path recomputes ``clock_timestamp()`` in the ``DO UPDATE SET``
|
|
47
|
+
# clause rather than copying ``excluded.*``: the ``excluded`` row is the
|
|
48
|
+
# ``VALUES`` tuple, evaluated before the statement blocked on the row lock, so
|
|
49
|
+
# reusing it would stamp a pre-wait expiry and reintroduce the stale-clock race.
|
|
50
|
+
# The
|
|
51
|
+
# takeover predicate keeps the transaction snapshot clock (``now()``): the
|
|
52
|
+
# takeover decision is a single point-in-time read and staying on the snapshot
|
|
53
|
+
# is the conservative choice (a waiter never over-eagerly steals a lease that
|
|
54
|
+
# was refreshed while it was blocked on the lock).
|
|
55
|
+
#
|
|
56
|
+
# ``RETURNING`` reports the lease STILL REMAINING (``expires_at`` minus the
|
|
57
|
+
# database's OWN clock, ``clock_timestamp()``, in the same statement) so the
|
|
58
|
+
# caller can anchor its local monotonic deadline to the database's authoritative
|
|
59
|
+
# remaining lease rather than to a Python instant. Both the stored expiry and
|
|
60
|
+
# the returned ``db_now`` come from the one server clock, so the remaining is
|
|
61
|
+
# correct regardless of how long the statement waited on the row lock. A
|
|
62
|
+
# conflict no-op (another replica owns an unexpired lease) matches no row and
|
|
63
|
+
# RETURNS nothing, so the caller reads it as "not acquired". See ``try_acquire``
|
|
64
|
+
# for why anchoring a POST-statement monotonic instant to this remaining fixes
|
|
65
|
+
# both the backdate (a pre-statement instant seeds an already-expired deadline
|
|
66
|
+
# after a lock wait) and the outrun (a post-commit instant overshoots the DB
|
|
67
|
+
# expiry) failure modes at once.
|
|
68
|
+
_POSTGRES_ACQUIRE_SQL = text(
|
|
69
|
+
"""
|
|
70
|
+
INSERT INTO scheduler_leader (id, leader_id, acquired_at, expires_at)
|
|
71
|
+
VALUES (1, :leader_id, clock_timestamp(), clock_timestamp() + make_interval(secs => :ttl))
|
|
72
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
73
|
+
leader_id = excluded.leader_id,
|
|
74
|
+
acquired_at = clock_timestamp(),
|
|
75
|
+
expires_at = clock_timestamp() + make_interval(secs => :ttl)
|
|
76
|
+
WHERE scheduler_leader.expires_at < now() OR scheduler_leader.leader_id = :leader_id
|
|
77
|
+
RETURNING extract(epoch FROM (expires_at - clock_timestamp()))
|
|
78
|
+
"""
|
|
79
|
+
).bindparams(bindparam("ttl", type_=Float))
|
|
80
|
+
|
|
81
|
+
# The renewal is conditional on the lease still being unexpired at
|
|
82
|
+
# statement-execution time (``expires_at > clock_timestamp()``), matching the
|
|
83
|
+
# same clock domain as the ``SET`` expiry. Keying only on ``id`` + ``leader_id``
|
|
84
|
+
# is not enough: if a heartbeat is delayed past the lease deadline (event-loop
|
|
85
|
+
# stall, row-lock wait) the row can already have expired while no follower has
|
|
86
|
+
# claimed it yet, and an unconditional UPDATE would resurrect that dead lease —
|
|
87
|
+
# extending leader-gated work past the TTL and delaying/overlapping failover. A
|
|
88
|
+
# renewal that finds the lease already expired matches 0 rows, which the caller
|
|
89
|
+
# treats as lease loss and demotes on (see :meth:`renew`), letting the follower
|
|
90
|
+
# take the row cleanly. ``clock_timestamp()`` (not ``now()``) is used so a
|
|
91
|
+
# renewal that blocked on the row lock is judged against the CURRENT time, not
|
|
92
|
+
# the transaction-start snapshot captured before it waited. ``RETURNING`` yields
|
|
93
|
+
# the renewed lease's remaining (``expires_at`` minus the same server clock) so
|
|
94
|
+
# :meth:`renew` can re-anchor its local deadline to the database's authoritative
|
|
95
|
+
# remaining lease; a rowcount-0 no-match RETURNS nothing (read as lease loss).
|
|
96
|
+
_POSTGRES_RENEW_SQL = text(
|
|
97
|
+
"""
|
|
98
|
+
UPDATE scheduler_leader
|
|
99
|
+
SET expires_at = clock_timestamp() + make_interval(secs => :ttl)
|
|
100
|
+
WHERE id = 1 AND leader_id = :leader_id AND expires_at > clock_timestamp()
|
|
101
|
+
RETURNING extract(epoch FROM (expires_at - clock_timestamp()))
|
|
102
|
+
"""
|
|
103
|
+
).bindparams(bindparam("ttl", type_=Float))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# SQLite evaluates the lease clock inside the statement using its OWN
|
|
107
|
+
# execution-time clock, mirroring the PostgreSQL ``clock_timestamp()`` fix
|
|
108
|
+
# above. A shared SQLite file implies a single host, so there is one clock
|
|
109
|
+
# domain — but a renewal or same-leader re-acquire can still sit behind SQLite's
|
|
110
|
+
# single-writer lock (``busy_timeout``, default ~30s) for far longer than the
|
|
111
|
+
# lease TTL (the minimum is 5s). Binding a Python ``now`` captured BEFORE
|
|
112
|
+
# ``session.execute`` would then evaluate the unexpired-lease guard AND the new
|
|
113
|
+
# ``expires_at`` against a STALE pre-wait instant once the write lock is finally
|
|
114
|
+
# acquired: a heartbeat that should have lost the lease could still match and
|
|
115
|
+
# extend the row, running leader-gated work past the real TTL and overlapping
|
|
116
|
+
# failover. Computing the clock in-SQL removes that gap — SQLite evaluates
|
|
117
|
+
# ``'now'`` once per ``sqlite3_step()`` (every ``strftime(..., 'now')`` in one
|
|
118
|
+
# statement shares that single instant, so the guard predicate and the ``SET``
|
|
119
|
+
# expiry stay in one clock domain) and only after the write lock is actually
|
|
120
|
+
# held, exactly like ``clock_timestamp()`` on PostgreSQL. ``strftime('%Y-%m-%d
|
|
121
|
+
# %H:%M:%f', 'now')`` renders a 3-digit-millisecond fractional field;
|
|
122
|
+
# ``|| '000'`` pads it to the 6-digit-microsecond WIDTH SQLAlchemy's
|
|
123
|
+
# ``DateTime`` persists, so the stored string stays lexicographically
|
|
124
|
+
# comparable with existing rows and with the acquire path. SQLite's ``'now'``
|
|
125
|
+
# is UTC, matching the naive-UTC wall clock SQLAlchemy writes for the tz-aware
|
|
126
|
+
# ``expires_at`` column, so no extra anchoring is needed. The takeover
|
|
127
|
+
# predicate necessarily shares this same execution-time clock (SQLite has no
|
|
128
|
+
# separate transaction-snapshot clock within a statement).
|
|
129
|
+
_SQLITE_NOW = "(strftime('%Y-%m-%d %H:%M:%f', 'now') || '000')"
|
|
130
|
+
_SQLITE_NOW_PLUS_TTL = "(strftime('%Y-%m-%d %H:%M:%f', 'now', '+' || :ttl || ' seconds') || '000')"
|
|
131
|
+
# Lease still remaining, in seconds, computed entirely on SQLite's own clock:
|
|
132
|
+
# ``expires_at`` (the string this module stores) minus ``'now'``, both evaluated
|
|
133
|
+
# in the SAME statement step so they share one instant. ``RETURNING`` this lets
|
|
134
|
+
# the caller anchor its local monotonic deadline to the database's authoritative
|
|
135
|
+
# remaining lease instead of a Python instant — see ``try_acquire`` — so the
|
|
136
|
+
# deadline neither backdates (a pre-statement Python instant seeds an
|
|
137
|
+
# already-expired deadline once the write lock is finally acquired after a long
|
|
138
|
+
# ``busy_timeout`` wait) nor outruns (a post-commit instant overshoots the row).
|
|
139
|
+
# ``julianday`` parses the 6-digit-microsecond-width string the acquire/renewal
|
|
140
|
+
# paths persist; the difference in fractional days is scaled to seconds.
|
|
141
|
+
_SQLITE_REMAINING = "((julianday(expires_at) - julianday('now')) * 86400.0)"
|
|
142
|
+
|
|
143
|
+
_SQLITE_ACQUIRE_SQL = text(
|
|
144
|
+
f"""
|
|
145
|
+
INSERT INTO scheduler_leader (id, leader_id, acquired_at, expires_at)
|
|
146
|
+
VALUES (1, :leader_id, {_SQLITE_NOW}, {_SQLITE_NOW_PLUS_TTL})
|
|
147
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
148
|
+
leader_id = excluded.leader_id,
|
|
149
|
+
acquired_at = {_SQLITE_NOW},
|
|
150
|
+
expires_at = {_SQLITE_NOW_PLUS_TTL}
|
|
151
|
+
WHERE scheduler_leader.expires_at < {_SQLITE_NOW} OR scheduler_leader.leader_id = :leader_id
|
|
152
|
+
RETURNING {_SQLITE_REMAINING}
|
|
153
|
+
"""
|
|
154
|
+
).bindparams(bindparam("ttl", type_=Float))
|
|
155
|
+
|
|
156
|
+
# The SQLite renewal mirrors ``_POSTGRES_RENEW_SQL``: it extends ``expires_at``
|
|
157
|
+
# from the execution-time clock and is conditional on the lease still being
|
|
158
|
+
# unexpired at that same clock, so a heartbeat delayed past the deadline (event-
|
|
159
|
+
# loop stall, single-writer-lock wait) matches 0 rows instead of resurrecting a
|
|
160
|
+
# dead row that no follower has claimed yet. Shared by :meth:`renew` and the
|
|
161
|
+
# release/drain path (:meth:`_renew_lease_row`).
|
|
162
|
+
_SQLITE_RENEW_SQL = text(
|
|
163
|
+
f"""
|
|
164
|
+
UPDATE scheduler_leader
|
|
165
|
+
SET expires_at = {_SQLITE_NOW_PLUS_TTL}
|
|
166
|
+
WHERE id = 1 AND leader_id = :leader_id AND expires_at > {_SQLITE_NOW}
|
|
167
|
+
RETURNING {_SQLITE_REMAINING}
|
|
168
|
+
"""
|
|
169
|
+
).bindparams(bindparam("ttl", type_=Float))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _dialect_name(session: AsyncSession) -> str:
|
|
173
|
+
return session.get_bind().dialect.name
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _returned_remaining(result: Result[Any]) -> float | None:
|
|
177
|
+
"""Return the DB-computed remaining lease seconds, or ``None`` on no-match.
|
|
178
|
+
|
|
179
|
+
The acquire/renew statements ``RETURNING`` the affected row's remaining
|
|
180
|
+
lease (``expires_at`` minus the database's OWN clock, computed in the same
|
|
181
|
+
statement so both share one instant). A single row means the write took
|
|
182
|
+
effect and its value is the authoritative remaining lease the caller
|
|
183
|
+
anchors its local monotonic deadline to; no row means the statement matched
|
|
184
|
+
nothing — another replica owns the lease, or a guarded renewal found it
|
|
185
|
+
already expired — which the caller treats as "not acquired / lease lost".
|
|
186
|
+
Relying on the RETURNING row (not ``rowcount``) is also portable: SQLite's
|
|
187
|
+
driver reports ``rowcount`` as ``-1`` for ``RETURNING`` statements.
|
|
188
|
+
"""
|
|
189
|
+
row = result.first()
|
|
190
|
+
if row is None:
|
|
191
|
+
return None
|
|
192
|
+
return float(row[0])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class LeaderElection:
|
|
196
|
+
def __init__(self, leader_id: str | None = None) -> None:
|
|
197
|
+
self._leader_id = leader_id or str(uuid.uuid4())
|
|
198
|
+
self._is_leader = False
|
|
199
|
+
# Monotonic (event-loop clock) estimate of when the lease we hold
|
|
200
|
+
# expires, set on every successful acquire/renew and cleared on
|
|
201
|
+
# authoritative loss. ``None`` means no lease is currently held. It is
|
|
202
|
+
# deliberately conservative: the database clock may grant slightly
|
|
203
|
+
# more, never less. A transient acquisition failure observed before
|
|
204
|
+
# this deadline passes must not demote an already-held lease.
|
|
205
|
+
self._lease_deadline: float | None = None
|
|
206
|
+
# Gated bodies that were detached after the cancellation grace and may
|
|
207
|
+
# still be draining shielded singleton work as the (former) leader.
|
|
208
|
+
self._detached_bodies: set[asyncio.Task[Any]] = set()
|
|
209
|
+
# Renewal tasks abandoned after their time-box elapsed. The heartbeat
|
|
210
|
+
# must not await a stalled renewal's (possibly hung) cancellation
|
|
211
|
+
# unwinding, so it drops the task here with a done callback that
|
|
212
|
+
# consumes the result. Keeping a strong reference prevents the loop
|
|
213
|
+
# from garbage-collecting a still-pending task mid-flight.
|
|
214
|
+
self._abandoned_renewals: set[asyncio.Task[bool]] = set()
|
|
215
|
+
# The single process-level lease-renewal keeper for the graceful-
|
|
216
|
+
# shutdown window. Started once at shutdown-begin (before ANY scheduler
|
|
217
|
+
# is stopped) and stopped by ``release``. Because schedulers are stopped
|
|
218
|
+
# one at a time and only the LAST one triggers ``release``, an earlier
|
|
219
|
+
# scheduler's ``stop()`` can detach a shielded gated body — which cancels
|
|
220
|
+
# that scheduler's own heartbeat — while later schedulers are still
|
|
221
|
+
# stopping and ``release`` has not begun its drain-renewal yet. This
|
|
222
|
+
# keeper owns lease renewal continuously across that whole gap so the DB
|
|
223
|
+
# lease can never expire while any detached/draining leader-gated body
|
|
224
|
+
# could still be acting as leader.
|
|
225
|
+
self._release_keeper: asyncio.Task[None] | None = None
|
|
226
|
+
self._release_keeper_stop: asyncio.Event | None = None
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def leader_id(self) -> str:
|
|
230
|
+
return self._leader_id
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_leader(self) -> bool:
|
|
234
|
+
return self._is_leader
|
|
235
|
+
|
|
236
|
+
async def try_acquire(self) -> bool:
|
|
237
|
+
settings = get_settings()
|
|
238
|
+
if not settings.leader_election_enabled:
|
|
239
|
+
self._is_leader = True
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
ttl = settings.leader_election_ttl_seconds
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
try:
|
|
245
|
+
async with get_background_session() as session:
|
|
246
|
+
acquire_sql = _SQLITE_ACQUIRE_SQL if _dialect_name(session) == "sqlite" else _POSTGRES_ACQUIRE_SQL
|
|
247
|
+
result = await session.execute(
|
|
248
|
+
acquire_sql,
|
|
249
|
+
{"leader_id": self._leader_id, "ttl": ttl},
|
|
250
|
+
)
|
|
251
|
+
# The statement RETURNS the lease still remaining, measured on
|
|
252
|
+
# the database's OWN clock (``expires_at`` minus ``db_now`` in
|
|
253
|
+
# the same statement), or nothing when another replica owns the
|
|
254
|
+
# lease. Capture the monotonic instant right AFTER the statement
|
|
255
|
+
# returns and add the DB-remaining to it: the local deadline then
|
|
256
|
+
# tracks the database's authoritative expiry precisely. Anchoring
|
|
257
|
+
# a PRE-statement instant would backdate the deadline by however
|
|
258
|
+
# long the statement waited on the row / write lock (a lock wait
|
|
259
|
+
# can exceed a short TTL, seeding an already-expired deadline);
|
|
260
|
+
# anchoring after ``commit()`` would push it past the true DB
|
|
261
|
+
# expiry by the commit round-trip. Reading the DB-remaining after
|
|
262
|
+
# a post-statement instant avoids both.
|
|
263
|
+
remaining = _returned_remaining(result)
|
|
264
|
+
acquired_at = loop.time()
|
|
265
|
+
if remaining is None:
|
|
266
|
+
# No RETURNING row is an AUTHORITATIVE non-owner result:
|
|
267
|
+
# another replica owns an unexpired lease. Demote BEFORE
|
|
268
|
+
# committing — mirroring :meth:`renew`'s rowcount-0 path — so
|
|
269
|
+
# that a ``commit()`` failure on a flaky connection cannot
|
|
270
|
+
# re-raise into the generic ``except`` below, whose
|
|
271
|
+
# preservation branch (``_is_leader`` + unexpired deadline)
|
|
272
|
+
# would keep leadership and let ``run_if_leader`` run ANOTHER
|
|
273
|
+
# singleton body even though the database row is owned by a
|
|
274
|
+
# different leader. The non-owner verdict is captured before
|
|
275
|
+
# the commit and is authoritative regardless of whether the
|
|
276
|
+
# (functionally no-op) commit then succeeds. Only a genuine
|
|
277
|
+
# connection error observed BEFORE this authoritative row
|
|
278
|
+
# result may fall through to the preservation branch.
|
|
279
|
+
self._is_leader = False
|
|
280
|
+
self._lease_deadline = None
|
|
281
|
+
try:
|
|
282
|
+
await session.commit()
|
|
283
|
+
except Exception:
|
|
284
|
+
logger.warning(
|
|
285
|
+
"Leader lease acquisition observed a non-owner result (no row) but "
|
|
286
|
+
"the commit failed; treating the lease as not acquired leader_id=%s",
|
|
287
|
+
self._leader_id,
|
|
288
|
+
exc_info=True,
|
|
289
|
+
)
|
|
290
|
+
return False
|
|
291
|
+
await session.commit()
|
|
292
|
+
except Exception:
|
|
293
|
+
# A transient acquisition failure must not demote a lease this
|
|
294
|
+
# instance already holds and whose locally tracked deadline has
|
|
295
|
+
# not passed. The leader election is a shared singleton across
|
|
296
|
+
# every singleton scheduler, so one scheduler tick can call
|
|
297
|
+
# ``try_acquire`` while another scheduler's gated body is still
|
|
298
|
+
# the valid leader; clearing ``_is_leader`` here would make that
|
|
299
|
+
# body's next ``renew`` return ``False`` without touching the
|
|
300
|
+
# database and cancel otherwise-valid leader work. Demotion is
|
|
301
|
+
# reserved for an authoritative non-owner result (the no-row branch
|
|
302
|
+
# ABOVE, which returns before reaching here even if its commit fails)
|
|
303
|
+
# or a failure observed after the held lease has already expired.
|
|
304
|
+
if self._is_leader and self._lease_deadline is not None and loop.time() < self._lease_deadline:
|
|
305
|
+
logger.warning(
|
|
306
|
+
"Leader lease acquisition failed but the held lease is still valid; "
|
|
307
|
+
"preserving leadership leader_id=%s",
|
|
308
|
+
self._leader_id,
|
|
309
|
+
exc_info=True,
|
|
310
|
+
)
|
|
311
|
+
return True
|
|
312
|
+
logger.warning("Leader election failed, defaulting to non-leader", exc_info=True)
|
|
313
|
+
self._is_leader = False
|
|
314
|
+
self._lease_deadline = None
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
# A no-row (authoritative non-owner) result already returned False above,
|
|
318
|
+
# so ``remaining`` is the DB-RETURNED remaining lease of a row this
|
|
319
|
+
# statement wrote. Anchor the local deadline to it and promote.
|
|
320
|
+
self._is_leader = True
|
|
321
|
+
self._lease_deadline = acquired_at + remaining
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
async def renew(self) -> bool:
|
|
325
|
+
"""Extend the held lease; demote when the lease is no longer ours.
|
|
326
|
+
|
|
327
|
+
Raises on database errors so callers can distinguish a lost lease
|
|
328
|
+
(returns ``False``) from a transient renewal failure.
|
|
329
|
+
"""
|
|
330
|
+
if not self._is_leader:
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
settings = get_settings()
|
|
334
|
+
if not settings.leader_election_enabled:
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
ttl = settings.leader_election_ttl_seconds
|
|
338
|
+
loop = asyncio.get_running_loop()
|
|
339
|
+
async with get_background_session() as session:
|
|
340
|
+
# Both dialects evaluate the unexpired-lease guard and the new expiry
|
|
341
|
+
# on the database's own execution-time clock, so a renewal delayed
|
|
342
|
+
# past the deadline (event-loop stall, row/write-lock wait) matches 0
|
|
343
|
+
# rows instead of resurrecting a row whose ``expires_at`` has already
|
|
344
|
+
# passed but no follower has yet claimed. A no-match RETURNS no row
|
|
345
|
+
# and falls through to the lease-loss demotion below.
|
|
346
|
+
renew_sql = _SQLITE_RENEW_SQL if _dialect_name(session) == "sqlite" else _POSTGRES_RENEW_SQL
|
|
347
|
+
result = await session.execute(
|
|
348
|
+
renew_sql,
|
|
349
|
+
{"leader_id": self._leader_id, "ttl": ttl},
|
|
350
|
+
)
|
|
351
|
+
# The statement RETURNS the renewed lease's remaining (on the
|
|
352
|
+
# database's OWN clock) or nothing. Read it BEFORE the commit: a
|
|
353
|
+
# no-match means another replica now owns the lease (or the row had
|
|
354
|
+
# expired), an authoritative verdict independent of whether the
|
|
355
|
+
# (functionally no-op) commit then succeeds. Capture the monotonic
|
|
356
|
+
# instant right AFTER the statement returns to anchor the renewed
|
|
357
|
+
# deadline against the DB-remaining below.
|
|
358
|
+
remaining = _returned_remaining(result)
|
|
359
|
+
renew_anchor = loop.time()
|
|
360
|
+
if remaining is None:
|
|
361
|
+
# Demote on the authoritative lease loss BEFORE committing, so a
|
|
362
|
+
# ``commit()`` failure on a flaky connection cannot re-raise out
|
|
363
|
+
# of ``renew()`` and be misread by the heartbeat as a transient
|
|
364
|
+
# renewal error. A transient error would keep the gated body
|
|
365
|
+
# running as a believed-leader until another error or the local
|
|
366
|
+
# deadline; a definitive rowcount-0 loss must demote and cancel
|
|
367
|
+
# the body immediately.
|
|
368
|
+
self._is_leader = False
|
|
369
|
+
self._lease_deadline = None
|
|
370
|
+
try:
|
|
371
|
+
await session.commit()
|
|
372
|
+
except Exception:
|
|
373
|
+
logger.warning(
|
|
374
|
+
"Leader lease renewal observed lease loss (rowcount 0) but the commit "
|
|
375
|
+
"failed; treating the lease as lost leader_id=%s",
|
|
376
|
+
self._leader_id,
|
|
377
|
+
exc_info=True,
|
|
378
|
+
)
|
|
379
|
+
return False
|
|
380
|
+
await session.commit()
|
|
381
|
+
|
|
382
|
+
# Extend the locally tracked deadline so a concurrent acquire that hits a
|
|
383
|
+
# transient error keeps preserving leadership for the full renewed lease,
|
|
384
|
+
# not just the original acquisition window. Anchor the POST-statement
|
|
385
|
+
# monotonic instant to the DB-returned remaining so the deadline tracks
|
|
386
|
+
# the database's authoritative expiry — never backdated by a lock wait,
|
|
387
|
+
# never outrunning the row.
|
|
388
|
+
self._lease_deadline = renew_anchor + remaining
|
|
389
|
+
return True
|
|
390
|
+
|
|
391
|
+
async def release(self) -> bool:
|
|
392
|
+
"""Delete the lease row we hold and report whether release completed.
|
|
393
|
+
|
|
394
|
+
Bodies detached after the cancellation grace may still be draining
|
|
395
|
+
shielded singleton work as the former leader, so the early release
|
|
396
|
+
first waits up to ``_RELEASE_DRAIN_GRACE_SECONDS`` for them WHILE it
|
|
397
|
+
keeps renewing the lease on the heartbeat cadence. A body detached on
|
|
398
|
+
the graceful-shutdown path is still the rightful leader, and with a
|
|
399
|
+
short TTL (the minimum is 5s) the DB lease would otherwise expire
|
|
400
|
+
during the drain wait — a follower could then acquire it and run the
|
|
401
|
+
same singleton work concurrently with the still-draining body. Renewing
|
|
402
|
+
while waiting keeps the lease alive for as long as the detached body may
|
|
403
|
+
still act as leader. If any body is still draining after the grace, the
|
|
404
|
+
row is left in place — the lease then expires after its TTL, roughly one
|
|
405
|
+
more TTL past the last renewal, after which the body is treated as
|
|
406
|
+
abandoned — because handing it to a follower while old gated work still
|
|
407
|
+
runs would recreate the duplicate-singleton overlap the lease exists to
|
|
408
|
+
prevent.
|
|
409
|
+
|
|
410
|
+
Renewal ownership is handed off from the shutdown keeper (see
|
|
411
|
+
:meth:`start_release_keeper`, started at shutdown-begin so the lease is
|
|
412
|
+
renewed continuously through the whole scheduler stop sequence). The
|
|
413
|
+
keeper is stopped FIRST so exactly one owner renews at a time: the keeper
|
|
414
|
+
renewed within the last interval, so the lease is still valid across the
|
|
415
|
+
synchronous handoff, and :meth:`_drain_detached_bodies` then renews on
|
|
416
|
+
the same cadence while it waits. Stopping the keeper before the drain
|
|
417
|
+
(rather than after) keeps a single renewal owner rather than two writers
|
|
418
|
+
racing on the row.
|
|
419
|
+
|
|
420
|
+
Failure to release must never block shutdown; the lease then simply
|
|
421
|
+
expires after the TTL.
|
|
422
|
+
"""
|
|
423
|
+
self._is_leader = False
|
|
424
|
+
self._lease_deadline = None
|
|
425
|
+
settings = get_settings()
|
|
426
|
+
if not settings.leader_election_enabled:
|
|
427
|
+
await self._stop_release_keeper()
|
|
428
|
+
return True
|
|
429
|
+
await self._stop_release_keeper()
|
|
430
|
+
if not await self._drain_detached_bodies():
|
|
431
|
+
logger.warning(
|
|
432
|
+
"Skipping early leader lease release: detached leader-gated work is still "
|
|
433
|
+
"draining after %.1fs; the lease will expire after its TTL",
|
|
434
|
+
_RELEASE_DRAIN_GRACE_SECONDS,
|
|
435
|
+
)
|
|
436
|
+
return False
|
|
437
|
+
try:
|
|
438
|
+
async with get_background_session() as session:
|
|
439
|
+
await session.execute(
|
|
440
|
+
delete(SchedulerLeader).where(
|
|
441
|
+
SchedulerLeader.id == 1,
|
|
442
|
+
SchedulerLeader.leader_id == self._leader_id,
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
await session.commit()
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
# A transient SQLite write-lock failure while deleting the row is
|
|
448
|
+
# not a release failure: the row is simply left in place and the
|
|
449
|
+
# lease expires after its TTL, exactly the fallback the caller
|
|
450
|
+
# already tolerates. Swallow it at DEBUG so shutdown never spams
|
|
451
|
+
# warnings on SQLite write contention; surface anything else as
|
|
452
|
+
# before. Shutdown is one-shot — there is no next release — so this
|
|
453
|
+
# site deliberately does NOT retry; ``sqlite_errorname`` is logged
|
|
454
|
+
# so an occurrence still says which mechanism held the slot.
|
|
455
|
+
if is_sqlite_lock_error(exc):
|
|
456
|
+
logger.debug(
|
|
457
|
+
"Leader lease release contended on a locked database; leaving the row "
|
|
458
|
+
"to expire after its TTL leader_id=%s sqlite_errorname=%s",
|
|
459
|
+
self._leader_id,
|
|
460
|
+
sqlite_error_name(exc),
|
|
461
|
+
exc_info=True,
|
|
462
|
+
)
|
|
463
|
+
return False
|
|
464
|
+
logger.warning("Failed to release scheduler leader lease", exc_info=True)
|
|
465
|
+
return False
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
def start_release_keeper(self) -> None:
|
|
469
|
+
"""Start the single lease-renewal keeper for the graceful-shutdown window.
|
|
470
|
+
|
|
471
|
+
Call this once at shutdown-begin, BEFORE stopping any scheduler. From
|
|
472
|
+
that moment until :meth:`release` stops it, one owner renews the
|
|
473
|
+
``scheduler_leader`` row on the ``max(1, ttl // 3)`` cadence, closing the
|
|
474
|
+
cross-scheduler stop-sequence gap: schedulers are stopped one at a time
|
|
475
|
+
and only the last one triggers ``release``, so an earlier scheduler's
|
|
476
|
+
``stop()`` can detach a shielded leader-gated body — which cancels that
|
|
477
|
+
scheduler's own heartbeat — while later schedulers are still stopping and
|
|
478
|
+
``release`` has not begun its own drain-renewal. Without this keeper the
|
|
479
|
+
DB lease could expire in that window (with the minimum 5s TTL, a later
|
|
480
|
+
scheduler that takes >= the TTL to drain is enough) while the detached
|
|
481
|
+
body still runs as leader, letting a follower acquire the lease and run
|
|
482
|
+
the same singleton work concurrently.
|
|
483
|
+
|
|
484
|
+
Idempotent; a no-op when leader election is disabled. The keeper renews
|
|
485
|
+
keyed on our ``leader_id`` (a harmless rowcount-0 no-op if this process
|
|
486
|
+
never held the lease or a follower already took it over), so it is safe
|
|
487
|
+
to start unconditionally at shutdown-begin regardless of current
|
|
488
|
+
leadership.
|
|
489
|
+
"""
|
|
490
|
+
if not get_settings().leader_election_enabled:
|
|
491
|
+
return
|
|
492
|
+
if self._release_keeper is not None and not self._release_keeper.done():
|
|
493
|
+
return
|
|
494
|
+
stop = asyncio.Event()
|
|
495
|
+
self._release_keeper_stop = stop
|
|
496
|
+
self._release_keeper = asyncio.create_task(self._run_release_keeper(stop))
|
|
497
|
+
|
|
498
|
+
async def _run_release_keeper(self, stop: asyncio.Event) -> None:
|
|
499
|
+
"""Renew the held lease row on the ttl/3 cadence until asked to stop.
|
|
500
|
+
|
|
501
|
+
This is the SINGLE renewal owner for the shutdown window that precedes
|
|
502
|
+
``release``. It renews BEFORE each wait so the lease is extended as soon
|
|
503
|
+
as the keeper starts, and it consults neither ``_is_leader`` nor the
|
|
504
|
+
locally tracked deadline: ``release`` clears both up-front, yet the row
|
|
505
|
+
is still ours while detached bodies drain. Renewal errors are swallowed
|
|
506
|
+
by :meth:`_renew_lease_row`; shutdown must always be able to proceed.
|
|
507
|
+
"""
|
|
508
|
+
renew_interval = max(1, get_settings().leader_election_ttl_seconds // 3)
|
|
509
|
+
try:
|
|
510
|
+
while not stop.is_set():
|
|
511
|
+
await self._renew_lease_row()
|
|
512
|
+
try:
|
|
513
|
+
await asyncio.wait_for(stop.wait(), timeout=renew_interval)
|
|
514
|
+
except TimeoutError:
|
|
515
|
+
pass
|
|
516
|
+
except asyncio.CancelledError:
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
async def _stop_release_keeper(self) -> None:
|
|
520
|
+
"""Stop the shutdown lease-renewal keeper if it is running.
|
|
521
|
+
|
|
522
|
+
Signals the keeper to exit and awaits it so renewal ownership passes
|
|
523
|
+
cleanly to ``release``'s own bounded drain-renewal with no overlapping
|
|
524
|
+
renewer. Cancels as a fallback so a keeper wedged inside a database call
|
|
525
|
+
cannot pin shutdown; the outer release deadline (``app/main.py``'s
|
|
526
|
+
``_release_leader_lease_within``) abandons the whole release if even that
|
|
527
|
+
stalls, so shutdown always proceeds.
|
|
528
|
+
"""
|
|
529
|
+
keeper = self._release_keeper
|
|
530
|
+
stop = self._release_keeper_stop
|
|
531
|
+
self._release_keeper = None
|
|
532
|
+
self._release_keeper_stop = None
|
|
533
|
+
if keeper is None:
|
|
534
|
+
return
|
|
535
|
+
if stop is not None:
|
|
536
|
+
stop.set()
|
|
537
|
+
keeper.cancel()
|
|
538
|
+
try:
|
|
539
|
+
await keeper
|
|
540
|
+
except asyncio.CancelledError:
|
|
541
|
+
pass
|
|
542
|
+
except Exception:
|
|
543
|
+
logger.warning("Scheduler leader lease keeper failed during shutdown", exc_info=True)
|
|
544
|
+
|
|
545
|
+
async def run_if_leader(self, fn: Callable[[], Awaitable[_T]]) -> _T | None:
|
|
546
|
+
"""Run ``fn`` only while holding the leader lease.
|
|
547
|
+
|
|
548
|
+
Heartbeats the lease every ``max(1, ttl // 3)`` seconds while the body
|
|
549
|
+
runs, except that each sleep is capped so the renewal it schedules
|
|
550
|
+
BEGINS with enough budget to FINISH before the locally tracked lease
|
|
551
|
+
deadline: the sleep is bounded by ``remaining - renew_timeout`` (clamped
|
|
552
|
+
to ``[0, renew_interval]``) so that ``sleep + renew_timeout`` lands at or
|
|
553
|
+
before the deadline. A lease seeded from a preserved acquire may have
|
|
554
|
+
less than a renew interval — even less than a whole renewal time-box —
|
|
555
|
+
left; sleeping the full interval (or the whole remaining) would start
|
|
556
|
+
``renew()`` at/after the deadline, so its time-box would run while the
|
|
557
|
+
database row has already expired and the gated body would keep acting as
|
|
558
|
+
leader for up to ``renew_timeout`` past the DB expiry while a follower is
|
|
559
|
+
free to acquire the row. Each renewal attempt is time-boxed to
|
|
560
|
+
``ttl / 6``, and the wait is additionally bounded so it never extends
|
|
561
|
+
past the local deadline: a renewal still in flight at the deadline is
|
|
562
|
+
abandoned and demoted rather than left running, so the gated body is
|
|
563
|
+
cancelled the instant the deadline is reached with no successful
|
|
564
|
+
renewal. Two consecutive failed attempts, or any failed attempt at/after
|
|
565
|
+
the locally tracked lease deadline, demote the holder no later than the
|
|
566
|
+
lease TTL — EXCEPT when a concurrent heartbeat on this shared
|
|
567
|
+
``LeaderElection`` has advanced ``self._lease_deadline`` past this
|
|
568
|
+
heartbeat's local deadline: because the election is a shared singleton
|
|
569
|
+
across every scheduler, clearing the shared leadership on one
|
|
570
|
+
heartbeat's local transient errors would cancel a sibling's still-valid
|
|
571
|
+
leader work and make its next ``renew`` return ``False`` even though the
|
|
572
|
+
DB lease is freshly held. In that case this heartbeat adopts the
|
|
573
|
+
sibling's fresher deadline and keeps renewing; the shared flag is cleared
|
|
574
|
+
only on an authoritative rowcount-0 loss or a genuinely expired shared
|
|
575
|
+
lease. The time-box is enforced with ``asyncio.wait`` (not
|
|
576
|
+
``asyncio.wait_for``) so a renewal whose cancellation cleanup itself
|
|
577
|
+
stalls — e.g. a blocked rollback in session teardown — cannot pin the
|
|
578
|
+
heartbeat past the timeout: once the timeout elapses the attempt is
|
|
579
|
+
abandoned and counted as an error immediately, without awaiting the
|
|
580
|
+
renewal coroutine's unwinding. On lease loss
|
|
581
|
+
the body is cancelled and awaited for at most
|
|
582
|
+
``_CANCEL_GRACE_SECONDS``; a body still draining shielded work after
|
|
583
|
+
the grace is detached (its outcome is logged from a done callback),
|
|
584
|
+
so ``run_if_leader`` itself returns within the grace of the loss.
|
|
585
|
+
|
|
586
|
+
When ``run_if_leader`` is instead cancelled externally (graceful
|
|
587
|
+
shutdown) while the lease is still held, the heartbeat keeps renewing
|
|
588
|
+
the lease until the gated body has drained (bounded by the cancel
|
|
589
|
+
grace); the heartbeat is stopped only after the body exits, so a body
|
|
590
|
+
that honours cancellation slower than the remaining lease TTL cannot let
|
|
591
|
+
the DB lease expire while it still runs as leader. If the body still has
|
|
592
|
+
not exited when the cancel grace elapses it is detached (and the
|
|
593
|
+
heartbeat stops), but the lease is not abandoned: ``release`` keeps
|
|
594
|
+
renewing it on the same cadence while it waits for the detached body to
|
|
595
|
+
drain, so the DB lease still cannot expire while a detached body may act
|
|
596
|
+
as leader.
|
|
597
|
+
|
|
598
|
+
Returns the body's result, or ``None`` when this replica is not
|
|
599
|
+
leader or the body was cancelled due to lease loss.
|
|
600
|
+
"""
|
|
601
|
+
if not await self.try_acquire():
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
settings = get_settings()
|
|
605
|
+
if not settings.leader_election_enabled:
|
|
606
|
+
return await fn()
|
|
607
|
+
|
|
608
|
+
ttl = settings.leader_election_ttl_seconds
|
|
609
|
+
renew_interval = max(1, ttl // 3)
|
|
610
|
+
renew_timeout = max(1.0, ttl / 6)
|
|
611
|
+
loop = asyncio.get_running_loop()
|
|
612
|
+
# Local monotonic estimate of when the lease we hold expires; extended
|
|
613
|
+
# on every successful renewal. This is deliberately conservative: the
|
|
614
|
+
# database clock may grant slightly more, never less.
|
|
615
|
+
#
|
|
616
|
+
# Seed it from the instance's DB-confirmed deadline rather than a fresh
|
|
617
|
+
# ``loop.time() + ttl``. ``try_acquire`` may have returned ``True``
|
|
618
|
+
# WITHOUT extending the database ``expires_at`` — the "preserve active
|
|
619
|
+
# leadership on a transient acquire error" path keeps an already-held
|
|
620
|
+
# lease alive but performs no DB write, so it leaves ``_lease_deadline``
|
|
621
|
+
# at the last value a real acquire/renew confirmed. Resetting to a full
|
|
622
|
+
# TTL here would let the local deadline drift PAST the true DB lease
|
|
623
|
+
# expiry, so the heartbeat could keep believing it leads after a
|
|
624
|
+
# follower has legitimately taken over the row. ``_lease_deadline`` is
|
|
625
|
+
# only ``None`` in the disabled escape hatch (handled above), so the
|
|
626
|
+
# fallback is defensive only.
|
|
627
|
+
lease_deadline = self._lease_deadline if self._lease_deadline is not None else loop.time() + ttl
|
|
628
|
+
# ``ensure_future`` accepts any awaitable (``create_task`` requires a
|
|
629
|
+
# coroutine), wrapping it in a task so it can be cancelled on lease loss.
|
|
630
|
+
body_task: asyncio.Task[_T] = asyncio.ensure_future(fn())
|
|
631
|
+
lease_lost = False
|
|
632
|
+
|
|
633
|
+
async def _heartbeat() -> None:
|
|
634
|
+
nonlocal lease_deadline, lease_lost
|
|
635
|
+
consecutive_errors = 0
|
|
636
|
+
inflight: asyncio.Task[bool] | None = None
|
|
637
|
+
try:
|
|
638
|
+
while True:
|
|
639
|
+
# Never sleep past the locally tracked lease deadline. A
|
|
640
|
+
# preserved acquire (a transient acquire error that kept an
|
|
641
|
+
# already-held lease) seeds ``lease_deadline`` from the last
|
|
642
|
+
# DB-confirmed expiry, which may be less than a full
|
|
643
|
+
# ``renew_interval`` out; sleeping the whole interval would
|
|
644
|
+
# keep the gated body running after the database row has
|
|
645
|
+
# expired, letting a follower acquire and run the same
|
|
646
|
+
# singleton work concurrently. Bound each sleep (especially
|
|
647
|
+
# the first) by the time remaining, and demote immediately
|
|
648
|
+
# when the deadline has already passed rather than sleeping.
|
|
649
|
+
# ``run_if_leader`` is a SHARED singleton across every
|
|
650
|
+
# scheduler, so a concurrent heartbeat on the SAME
|
|
651
|
+
# ``LeaderElection`` instance may have renewed the row and
|
|
652
|
+
# advanced ``self._lease_deadline`` past this heartbeat's
|
|
653
|
+
# local view. Adopt that later shared deadline before judging
|
|
654
|
+
# expiry so a stale local deadline cannot demote (and cancel
|
|
655
|
+
# a sibling's still-valid leader work) while the DB lease is
|
|
656
|
+
# freshly held by the same process. ``self._lease_deadline``
|
|
657
|
+
# only ever advances via a real DB-confirmed acquire/renewal,
|
|
658
|
+
# so adopting it can never outrun the true lease.
|
|
659
|
+
if self._lease_deadline is not None and self._lease_deadline > lease_deadline:
|
|
660
|
+
lease_deadline = self._lease_deadline
|
|
661
|
+
remaining = lease_deadline - loop.time()
|
|
662
|
+
if remaining <= 0:
|
|
663
|
+
self._is_leader = False
|
|
664
|
+
self._lease_deadline = None
|
|
665
|
+
lease_lost = True
|
|
666
|
+
return
|
|
667
|
+
# Begin the renewal with enough budget to FINISH before the
|
|
668
|
+
# deadline: cap the sleep so ``sleep + renew_timeout`` lands
|
|
669
|
+
# at or before ``lease_deadline``. A preserved acquire can
|
|
670
|
+
# leave less than one renew interval — even less than a whole
|
|
671
|
+
# renewal time-box — on the lease; sleeping the full interval
|
|
672
|
+
# (or the whole remaining) would start ``renew()`` at/after
|
|
673
|
+
# the deadline, so its time-box would run while the database
|
|
674
|
+
# row has already expired and the gated body would keep
|
|
675
|
+
# acting as leader for up to ``renew_timeout`` past the DB
|
|
676
|
+
# expiry (a follower is free to acquire the row in that gap).
|
|
677
|
+
# When already within a time-box of the deadline the sleep is
|
|
678
|
+
# 0 and the renewal fires immediately.
|
|
679
|
+
sleep_budget = min(float(renew_interval), max(0.0, remaining - renew_timeout))
|
|
680
|
+
await asyncio.sleep(sleep_budget)
|
|
681
|
+
inflight = asyncio.ensure_future(self.renew())
|
|
682
|
+
# Time-box the attempt to ``renew_timeout``, but never wait
|
|
683
|
+
# PAST the local deadline: a renewal still in flight at the
|
|
684
|
+
# deadline is abandoned and demoted rather than left to keep
|
|
685
|
+
# the body running as leader past the lease expiry. Bounding
|
|
686
|
+
# the wait this way guarantees the heartbeat wakes AT the
|
|
687
|
+
# deadline (not a time-box later) so the gated body is
|
|
688
|
+
# cancelled the instant the deadline is reached with no
|
|
689
|
+
# successful renewal. ``asyncio.wait`` returns on the timeout
|
|
690
|
+
# without cancelling or awaiting ``inflight``, so a renewal
|
|
691
|
+
# whose cancellation cleanup itself hangs cannot pin the
|
|
692
|
+
# heartbeat past the time-box the way ``asyncio.wait_for``
|
|
693
|
+
# (which awaits the cancelled coroutine's unwinding) would.
|
|
694
|
+
renew_wait = min(renew_timeout, max(0.0, lease_deadline - loop.time()))
|
|
695
|
+
done, _ = await asyncio.wait({inflight}, timeout=renew_wait)
|
|
696
|
+
renew_task = inflight
|
|
697
|
+
inflight = None
|
|
698
|
+
try:
|
|
699
|
+
if renew_task not in done:
|
|
700
|
+
# Renewal stalled past its time-box. Abandon it
|
|
701
|
+
# without awaiting the unwind and treat the lease as
|
|
702
|
+
# at risk on the timeout deadline, not whenever the
|
|
703
|
+
# renewal finally returns.
|
|
704
|
+
self._abandon_renewal(renew_task)
|
|
705
|
+
raise TimeoutError("leader lease renewal timed out")
|
|
706
|
+
renewed = renew_task.result()
|
|
707
|
+
except Exception:
|
|
708
|
+
consecutive_errors += 1
|
|
709
|
+
logger.warning(
|
|
710
|
+
"Leader lease renewal errored or timed out consecutive_errors=%s",
|
|
711
|
+
consecutive_errors,
|
|
712
|
+
exc_info=True,
|
|
713
|
+
)
|
|
714
|
+
if consecutive_errors < _MAX_CONSECUTIVE_RENEW_ERRORS and loop.time() < lease_deadline:
|
|
715
|
+
continue
|
|
716
|
+
# This heartbeat lost local confidence (two consecutive
|
|
717
|
+
# transient renewal errors, or a failure at/after its
|
|
718
|
+
# local deadline). Before clearing the SHARED leadership
|
|
719
|
+
# state, consult the authoritative shared lease: because
|
|
720
|
+
# ``run_if_leader`` is a shared singleton across every
|
|
721
|
+
# scheduler, a concurrent heartbeat on the SAME instance
|
|
722
|
+
# may have renewed the row and advanced
|
|
723
|
+
# ``self._lease_deadline`` past this heartbeat's stale
|
|
724
|
+
# local deadline. Clearing ``self._is_leader`` /
|
|
725
|
+
# ``self._lease_deadline`` here would cancel that
|
|
726
|
+
# sibling's otherwise-valid leader work and make its next
|
|
727
|
+
# ``renew()`` return ``False`` without touching the
|
|
728
|
+
# database, even though the DB lease is freshly held.
|
|
729
|
+
# Demotion of the shared flag is authoritative only for a
|
|
730
|
+
# rowcount-0 loss (already cleared by ``renew`` and
|
|
731
|
+
# handled in the ``else`` branch) or a genuinely expired
|
|
732
|
+
# shared lease. When a sibling has advanced the shared
|
|
733
|
+
# deadline beyond this heartbeat's local view, adopt it
|
|
734
|
+
# and keep renewing rather than tearing down shared
|
|
735
|
+
# leadership; this heartbeat's own transient errors are
|
|
736
|
+
# not evidence the DB lease is gone.
|
|
737
|
+
if (
|
|
738
|
+
self._is_leader
|
|
739
|
+
and self._lease_deadline is not None
|
|
740
|
+
and self._lease_deadline > lease_deadline
|
|
741
|
+
):
|
|
742
|
+
lease_deadline = self._lease_deadline
|
|
743
|
+
consecutive_errors = 0
|
|
744
|
+
continue
|
|
745
|
+
self._is_leader = False
|
|
746
|
+
self._lease_deadline = None
|
|
747
|
+
renewed = False
|
|
748
|
+
else:
|
|
749
|
+
consecutive_errors = 0
|
|
750
|
+
if renewed and self._lease_deadline is not None:
|
|
751
|
+
# ``renew`` anchored ``_lease_deadline`` to the lease
|
|
752
|
+
# the database RETURNed as remaining (its ``expires_at``
|
|
753
|
+
# minus the DB's own clock) from a monotonic instant
|
|
754
|
+
# captured AFTER its statement, so the working deadline
|
|
755
|
+
# tracks the DB's authoritative expiry — neither
|
|
756
|
+
# backdated by a lock wait nor outrunning the row.
|
|
757
|
+
# Adopt it rather than recomputing a pre-dispatch
|
|
758
|
+
# ``loop.time() + ttl`` here.
|
|
759
|
+
lease_deadline = self._lease_deadline
|
|
760
|
+
if not renewed:
|
|
761
|
+
lease_lost = True
|
|
762
|
+
return
|
|
763
|
+
finally:
|
|
764
|
+
# If the heartbeat is itself cancelled (e.g. shutdown) while a
|
|
765
|
+
# renewal is in flight, abandon it so it does not leak.
|
|
766
|
+
if inflight is not None and not inflight.done():
|
|
767
|
+
self._abandon_renewal(inflight)
|
|
768
|
+
|
|
769
|
+
heartbeat_task = asyncio.create_task(_heartbeat())
|
|
770
|
+
# Whether the lease-loss branch already cancelled (and possibly
|
|
771
|
+
# detached) the body. The finally block must not cancel a second time:
|
|
772
|
+
# a body draining shielded work sits at a plain ``await inner`` in its
|
|
773
|
+
# CancelledError handler, and a second ``Task.cancel()`` would cancel
|
|
774
|
+
# that shielded inner task through the await.
|
|
775
|
+
body_cancel_handled = False
|
|
776
|
+
try:
|
|
777
|
+
done, _ = await asyncio.wait({body_task, heartbeat_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
778
|
+
if heartbeat_task in done and not lease_lost and not body_task.done():
|
|
779
|
+
# The heartbeat can only exit without flagging lease loss by
|
|
780
|
+
# crashing; without renewals leadership cannot be trusted.
|
|
781
|
+
logger.error(
|
|
782
|
+
"Leader heartbeat failed unexpectedly; demoting leader_id=%s",
|
|
783
|
+
self._leader_id,
|
|
784
|
+
exc_info=heartbeat_task.exception(),
|
|
785
|
+
)
|
|
786
|
+
self._is_leader = False
|
|
787
|
+
self._lease_deadline = None
|
|
788
|
+
lease_lost = True
|
|
789
|
+
if lease_lost:
|
|
790
|
+
logger.warning(
|
|
791
|
+
"Leader-gated task cancelled after lease loss leader_id=%s",
|
|
792
|
+
self._leader_id,
|
|
793
|
+
)
|
|
794
|
+
body_cancel_handled = True
|
|
795
|
+
await self._cancel_within_grace(body_task)
|
|
796
|
+
return None
|
|
797
|
+
return await body_task
|
|
798
|
+
finally:
|
|
799
|
+
# Shutdown-cancel path: ``run_if_leader`` was cancelled externally
|
|
800
|
+
# (e.g. a scheduler's ``stop()``) while the lease is still HELD —
|
|
801
|
+
# unlike the lease-loss branch above, which has already cancelled the
|
|
802
|
+
# body BECAUSE the lease was lost. Here the gated body may still be
|
|
803
|
+
# running as the rightful leader, e.g. draining a shielded token/
|
|
804
|
+
# usage refresh. Cancel and drain it FIRST, while the heartbeat keeps
|
|
805
|
+
# renewing the lease, and only stop the heartbeat once the body has
|
|
806
|
+
# exited (bounded by the cancel grace). Stopping renewals first — the
|
|
807
|
+
# old ordering — could let a body that honours cancellation slower
|
|
808
|
+
# than the remaining lease TTL (e.g. TTL=5s with the 5s cancel grace)
|
|
809
|
+
# outlive the DB lease, so a follower could acquire it and run the
|
|
810
|
+
# same singleton work concurrently. On the lease-loss / heartbeat-
|
|
811
|
+
# crash paths ``body_cancel_handled`` is already set, so the body is
|
|
812
|
+
# not re-cancelled here and the (already-returned) heartbeat is simply
|
|
813
|
+
# cancelled — the lease is genuinely gone there and MUST NOT be
|
|
814
|
+
# renewed.
|
|
815
|
+
if not body_cancel_handled and not body_task.done():
|
|
816
|
+
await self._cancel_within_grace(body_task)
|
|
817
|
+
heartbeat_task.cancel()
|
|
818
|
+
try:
|
|
819
|
+
await heartbeat_task
|
|
820
|
+
except asyncio.CancelledError:
|
|
821
|
+
pass
|
|
822
|
+
except Exception:
|
|
823
|
+
logger.exception("Leader heartbeat failed during cleanup")
|
|
824
|
+
|
|
825
|
+
async def _cancel_within_grace(self, task: asyncio.Task[Any]) -> None:
|
|
826
|
+
"""Cancel ``task`` and await it for at most ``_CANCEL_GRACE_SECONDS``.
|
|
827
|
+
|
|
828
|
+
Gated bodies may shield in-flight singleton refreshes (e.g. the token
|
|
829
|
+
and usage refresh singleflights) and drain them after a cancellation
|
|
830
|
+
request, so this uses ``asyncio.wait`` (which does not re-cancel on
|
|
831
|
+
timeout) and detaches the task after the grace instead of blocking on
|
|
832
|
+
the shielded upstream call. A detached task keeps draining in the
|
|
833
|
+
background bounded by the underlying operation's own timeout; it is
|
|
834
|
+
tracked so ``release`` will not hand the lease over while it may still
|
|
835
|
+
run, and its outcome is logged from a done callback so failures are
|
|
836
|
+
still observed.
|
|
837
|
+
"""
|
|
838
|
+
task.cancel()
|
|
839
|
+
_, pending = await asyncio.wait({task}, timeout=_CANCEL_GRACE_SECONDS)
|
|
840
|
+
if pending:
|
|
841
|
+
self._detached_bodies.add(task)
|
|
842
|
+
task.add_done_callback(self._on_detached_body_done)
|
|
843
|
+
logger.warning(
|
|
844
|
+
"Leader-gated task still draining shielded work %.1fs after cancellation; detaching",
|
|
845
|
+
_CANCEL_GRACE_SECONDS,
|
|
846
|
+
)
|
|
847
|
+
return
|
|
848
|
+
if task.cancelled():
|
|
849
|
+
return
|
|
850
|
+
exc = task.exception()
|
|
851
|
+
if exc is not None:
|
|
852
|
+
logger.warning("Leader-gated task failed while being cancelled", exc_info=exc)
|
|
853
|
+
|
|
854
|
+
def _abandon_renewal(self, task: asyncio.Task[bool]) -> None:
|
|
855
|
+
"""Request cancellation of a stalled renewal without awaiting its unwind.
|
|
856
|
+
|
|
857
|
+
The heartbeat already observed the time-box elapse, so leadership can
|
|
858
|
+
be treated as at risk immediately. Cancellation cleanup of a hung
|
|
859
|
+
database call (a blocked rollback or driver call during session
|
|
860
|
+
teardown) may itself block, so the task is dropped here — tracked with
|
|
861
|
+
a strong reference and a done callback that consumes its result — and
|
|
862
|
+
left to unwind in the background rather than blocking the loop.
|
|
863
|
+
"""
|
|
864
|
+
self._abandoned_renewals.add(task)
|
|
865
|
+
task.add_done_callback(self._on_renewal_done)
|
|
866
|
+
task.cancel()
|
|
867
|
+
|
|
868
|
+
def _on_renewal_done(self, task: asyncio.Task[bool]) -> None:
|
|
869
|
+
self._abandoned_renewals.discard(task)
|
|
870
|
+
if task.cancelled():
|
|
871
|
+
return
|
|
872
|
+
exc = task.exception()
|
|
873
|
+
if exc is not None:
|
|
874
|
+
logger.debug("Abandoned leader lease renewal finished with error", exc_info=exc)
|
|
875
|
+
|
|
876
|
+
def _on_detached_body_done(self, task: asyncio.Task[Any]) -> None:
|
|
877
|
+
self._detached_bodies.discard(task)
|
|
878
|
+
_log_detached_body_result(task)
|
|
879
|
+
|
|
880
|
+
async def _drain_detached_bodies(self) -> bool:
|
|
881
|
+
"""Wait for detached gated bodies, renewing the lease while they run.
|
|
882
|
+
|
|
883
|
+
Returns ``True`` when no detached body remains running (the row is then
|
|
884
|
+
safe to delete). A body detached on the graceful-shutdown path is still
|
|
885
|
+
the rightful leader while it drains shielded singleton work, so the DB
|
|
886
|
+
lease MUST NOT expire under it: this renews the lease on the heartbeat
|
|
887
|
+
cadence (``max(1, ttl // 3)``) for as long as it waits. The wait is
|
|
888
|
+
bounded by ``_RELEASE_DRAIN_GRACE_SECONDS`` so shutdown always proceeds;
|
|
889
|
+
if a body is still draining when the grace elapses this returns
|
|
890
|
+
``False`` and the caller leaves the row for the lease to expire by TTL
|
|
891
|
+
(the last renewal bought roughly one more TTL, after which the body is
|
|
892
|
+
treated as abandoned). A renewal that finds rowcount 0 — the lease was
|
|
893
|
+
already taken over on the lease-loss detach path — is a harmless no-op.
|
|
894
|
+
"""
|
|
895
|
+
pending = {task for task in self._detached_bodies if not task.done()}
|
|
896
|
+
if not pending:
|
|
897
|
+
return True
|
|
898
|
+
settings = get_settings()
|
|
899
|
+
renew_interval = max(1, settings.leader_election_ttl_seconds // 3)
|
|
900
|
+
loop = asyncio.get_running_loop()
|
|
901
|
+
logger.info(
|
|
902
|
+
"Waiting up to %.1fs for %d detached leader-gated task(s), renewing the lease "
|
|
903
|
+
"meanwhile, before releasing it",
|
|
904
|
+
_RELEASE_DRAIN_GRACE_SECONDS,
|
|
905
|
+
len(pending),
|
|
906
|
+
)
|
|
907
|
+
deadline = loop.time() + _RELEASE_DRAIN_GRACE_SECONDS
|
|
908
|
+
while pending:
|
|
909
|
+
remaining = deadline - loop.time()
|
|
910
|
+
if remaining <= 0:
|
|
911
|
+
return False
|
|
912
|
+
# Renew BEFORE each wait slice so the database lease cannot expire
|
|
913
|
+
# while a detached body may still be acting as leader.
|
|
914
|
+
await self._renew_lease_row()
|
|
915
|
+
_, pending = await asyncio.wait(pending, timeout=min(float(renew_interval), remaining))
|
|
916
|
+
return True
|
|
917
|
+
|
|
918
|
+
async def _renew_lease_row(self) -> bool:
|
|
919
|
+
"""Extend the lease row's expiry keyed on our ``leader_id``.
|
|
920
|
+
|
|
921
|
+
Unlike :meth:`renew` this consults neither the in-memory ``_is_leader``
|
|
922
|
+
flag nor the locally tracked deadline: it is used by ``release`` while a
|
|
923
|
+
body detached on the graceful-shutdown path may still be acting as
|
|
924
|
+
leader, at which point ``_is_leader`` has already been cleared but the
|
|
925
|
+
database row is still ours. Like :meth:`renew` the UPDATE is guarded on
|
|
926
|
+
the lease still being unexpired, so it can never resurrect a row whose
|
|
927
|
+
``expires_at`` has already passed (a follower is then free to take it).
|
|
928
|
+
Errors are swallowed (shutdown must proceed); the caller treats a
|
|
929
|
+
failure — or an expired/taken-over no-match — as "lease not renewed".
|
|
930
|
+
"""
|
|
931
|
+
settings = get_settings()
|
|
932
|
+
ttl = settings.leader_election_ttl_seconds
|
|
933
|
+
try:
|
|
934
|
+
async with get_background_session() as session:
|
|
935
|
+
# Guard the renewal on the lease still being unexpired at the
|
|
936
|
+
# database's execution-time clock so a shutdown/drain renewal can
|
|
937
|
+
# never resurrect a row whose ``expires_at`` has already passed.
|
|
938
|
+
renew_sql = _SQLITE_RENEW_SQL if _dialect_name(session) == "sqlite" else _POSTGRES_RENEW_SQL
|
|
939
|
+
result = await session.execute(
|
|
940
|
+
renew_sql,
|
|
941
|
+
{"leader_id": self._leader_id, "ttl": ttl},
|
|
942
|
+
)
|
|
943
|
+
# A RETURNING row means the guarded UPDATE extended the row; no
|
|
944
|
+
# row means it was expired/taken over (a harmless no-op here).
|
|
945
|
+
# The drain path holds no locally tracked deadline to re-anchor.
|
|
946
|
+
renewed = _returned_remaining(result) is not None
|
|
947
|
+
await session.commit()
|
|
948
|
+
return renewed
|
|
949
|
+
except Exception as exc:
|
|
950
|
+
# This renewal is best-effort: the caller (the release keeper and the
|
|
951
|
+
# drain loop) treats a ``False`` as "not renewed this cadence" and
|
|
952
|
+
# renews again on the next tick, so a transient SQLite write-lock
|
|
953
|
+
# failure must not raise out of the shutdown path nor spam warnings.
|
|
954
|
+
# Failing fast IS the retry here — an in-place retry would hold the
|
|
955
|
+
# cadence past its deadline — so log it at DEBUG with the driver's
|
|
956
|
+
# ``sqlite_errorname`` and let the next tick try; surface other
|
|
957
|
+
# errors as before.
|
|
958
|
+
if is_sqlite_lock_error(exc):
|
|
959
|
+
logger.debug(
|
|
960
|
+
"Leader lease renewal contended on a locked database; will retry next cadence "
|
|
961
|
+
"leader_id=%s sqlite_errorname=%s",
|
|
962
|
+
self._leader_id,
|
|
963
|
+
sqlite_error_name(exc),
|
|
964
|
+
exc_info=True,
|
|
965
|
+
)
|
|
966
|
+
return False
|
|
967
|
+
logger.warning(
|
|
968
|
+
"Failed to renew leader lease while draining detached bodies leader_id=%s",
|
|
969
|
+
self._leader_id,
|
|
970
|
+
exc_info=True,
|
|
971
|
+
)
|
|
972
|
+
return False
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _log_detached_body_result(task: asyncio.Task[Any]) -> None:
|
|
976
|
+
if task.cancelled():
|
|
977
|
+
logger.info("Detached leader-gated task finished cancelling after lease loss")
|
|
978
|
+
return
|
|
979
|
+
exc = task.exception()
|
|
980
|
+
if exc is not None:
|
|
981
|
+
logger.warning("Detached leader-gated task failed after lease loss", exc_info=exc)
|
|
982
|
+
else:
|
|
983
|
+
logger.info("Detached leader-gated task completed after lease loss")
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
_leader_election: LeaderElection | None = None
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def get_leader_election() -> LeaderElection:
|
|
990
|
+
global _leader_election
|
|
991
|
+
if _leader_election is None:
|
|
992
|
+
_leader_election = LeaderElection()
|
|
993
|
+
return _leader_election
|