attocode-code-intel 0.1.0__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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Embedding rotation state machine for the local ``vectors`` store.
|
|
2
|
+
|
|
3
|
+
Migrates every stored embedding from one model to another without ever
|
|
4
|
+
touching (let alone wiping) the live rows — the opposite of the
|
|
5
|
+
destructive-NULL path Phase 1 killed.
|
|
6
|
+
|
|
7
|
+
States::
|
|
8
|
+
|
|
9
|
+
none
|
|
10
|
+
│ embeddings_rotate_start(new_model, new_dim)
|
|
11
|
+
▼
|
|
12
|
+
pending ─── staging table created, total row count captured
|
|
13
|
+
│
|
|
14
|
+
│ embeddings_rotate_step(batch_size) (call repeatedly)
|
|
15
|
+
▼
|
|
16
|
+
backfilling ── each batch: read N old rows, embed with new provider,
|
|
17
|
+
│ insert into vectors_rotating
|
|
18
|
+
│
|
|
19
|
+
│ last batch processed (cursor exhausted)
|
|
20
|
+
▼
|
|
21
|
+
ready_to_cutover
|
|
22
|
+
│
|
|
23
|
+
│ embeddings_rotate_cutover(confirm=True)
|
|
24
|
+
▼ ┌── rename vectors → vectors_archive
|
|
25
|
+
cutover_done └── rename vectors_rotating → vectors
|
|
26
|
+
│ update store_metadata.dimension
|
|
27
|
+
│ embeddings_rotate_gc_old(confirm=True)
|
|
28
|
+
▼
|
|
29
|
+
gc_done ──── vectors_archive dropped; rotation metadata cleared
|
|
30
|
+
│
|
|
31
|
+
▼
|
|
32
|
+
none
|
|
33
|
+
|
|
34
|
+
At any pre-cutover state, ``embeddings_rotate_abort(confirm=True)`` drops
|
|
35
|
+
``vectors_rotating`` and resets state to ``none`` — the primary ``vectors``
|
|
36
|
+
table is untouched.
|
|
37
|
+
|
|
38
|
+
**Known limitation:** new writes to ``vectors`` that happen DURING rotation
|
|
39
|
+
(e.g. ``notify_file_changed`` triggering a reindex mid-rotation) land in
|
|
40
|
+
the old table and never reach ``vectors_rotating``. After cutover they're
|
|
41
|
+
effectively missing. For Phase 2b the recommendation is to avoid concurrent
|
|
42
|
+
indexing during a rotation, or run a normal ``semantic_search`` reindex
|
|
43
|
+
afterwards to catch up.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import logging
|
|
49
|
+
import os
|
|
50
|
+
import sqlite3
|
|
51
|
+
import struct
|
|
52
|
+
import time
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from enum import StrEnum
|
|
55
|
+
from typing import TYPE_CHECKING
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from attocode_intel._internal.integrations.context.embeddings import EmbeddingProvider
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
# Staging table name used for the rotation target.
|
|
63
|
+
STAGING_TABLE = "vectors_rotating"
|
|
64
|
+
# Archive table name used to hold the pre-rotation rows until GC.
|
|
65
|
+
ARCHIVE_TABLE = "vectors_archive"
|
|
66
|
+
|
|
67
|
+
# All store_metadata keys the rotator owns. Clearing these drops all
|
|
68
|
+
# rotation state back to a clean slate.
|
|
69
|
+
_ROTATION_KEYS: tuple[str, ...] = (
|
|
70
|
+
"rotation_state",
|
|
71
|
+
"rotation_from_model",
|
|
72
|
+
"rotation_from_version",
|
|
73
|
+
"rotation_from_dim",
|
|
74
|
+
"rotation_to_model",
|
|
75
|
+
"rotation_to_version",
|
|
76
|
+
"rotation_to_dim",
|
|
77
|
+
"rotation_started_at",
|
|
78
|
+
"rotation_cursor",
|
|
79
|
+
"rotation_processed_rows",
|
|
80
|
+
"rotation_total_rows",
|
|
81
|
+
"rotation_last_error",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RotationState(StrEnum):
|
|
86
|
+
"""Finite state machine for one embedding rotation."""
|
|
87
|
+
|
|
88
|
+
NONE = "none"
|
|
89
|
+
PENDING = "pending"
|
|
90
|
+
BACKFILLING = "backfilling"
|
|
91
|
+
READY_TO_CUTOVER = "ready_to_cutover"
|
|
92
|
+
CUTOVER_DONE = "cutover_done"
|
|
93
|
+
GC_DONE = "gc_done"
|
|
94
|
+
ABORTED = "aborted"
|
|
95
|
+
FAILED = "failed"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(slots=True)
|
|
99
|
+
class RotationStatus:
|
|
100
|
+
"""Public snapshot of the current rotation (or lack thereof)."""
|
|
101
|
+
|
|
102
|
+
state: RotationState
|
|
103
|
+
from_model: str
|
|
104
|
+
from_version: str
|
|
105
|
+
from_dim: int
|
|
106
|
+
to_model: str
|
|
107
|
+
to_version: str
|
|
108
|
+
to_dim: int
|
|
109
|
+
started_at: float
|
|
110
|
+
processed_rows: int
|
|
111
|
+
total_rows: int
|
|
112
|
+
last_error: str
|
|
113
|
+
|
|
114
|
+
def is_active(self) -> bool:
|
|
115
|
+
return self.state not in (
|
|
116
|
+
RotationState.NONE,
|
|
117
|
+
RotationState.GC_DONE,
|
|
118
|
+
RotationState.ABORTED,
|
|
119
|
+
RotationState.FAILED,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def progress_pct(self) -> float:
|
|
123
|
+
if self.total_rows <= 0:
|
|
124
|
+
return 0.0
|
|
125
|
+
return 100.0 * self.processed_rows / self.total_rows
|
|
126
|
+
|
|
127
|
+
def to_dict(self) -> dict[str, object]:
|
|
128
|
+
return {
|
|
129
|
+
"state": self.state.value,
|
|
130
|
+
"from": {
|
|
131
|
+
"model": self.from_model,
|
|
132
|
+
"version": self.from_version,
|
|
133
|
+
"dim": self.from_dim,
|
|
134
|
+
},
|
|
135
|
+
"to": {
|
|
136
|
+
"model": self.to_model,
|
|
137
|
+
"version": self.to_version,
|
|
138
|
+
"dim": self.to_dim,
|
|
139
|
+
},
|
|
140
|
+
"started_at": self.started_at,
|
|
141
|
+
"processed_rows": self.processed_rows,
|
|
142
|
+
"total_rows": self.total_rows,
|
|
143
|
+
"progress_pct": round(self.progress_pct(), 2),
|
|
144
|
+
"last_error": self.last_error,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# EmbeddingRotator
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class EmbeddingRotator:
|
|
154
|
+
"""Rotation driver operating directly on a ``vectors.db`` file.
|
|
155
|
+
|
|
156
|
+
Deliberately bypasses ``VectorStore`` so that a dimension mismatch
|
|
157
|
+
between the old and new models doesn't trip ``_validate_dimension``.
|
|
158
|
+
All reads / writes go through raw ``sqlite3``; the rotator is
|
|
159
|
+
responsible for its own locking.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, db_path: str) -> None:
|
|
163
|
+
self.db_path = db_path
|
|
164
|
+
self._conn: sqlite3.Connection | None = None
|
|
165
|
+
if os.path.exists(db_path):
|
|
166
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
167
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
168
|
+
|
|
169
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
170
|
+
if self._conn is None:
|
|
171
|
+
raise RuntimeError(
|
|
172
|
+
f"EmbeddingRotator: no vectors.db at {self.db_path}"
|
|
173
|
+
)
|
|
174
|
+
return self._conn
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
if self._conn:
|
|
178
|
+
self._conn.close()
|
|
179
|
+
self._conn = None
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Metadata helpers
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _set_meta(self, key: str, value: str) -> None:
|
|
186
|
+
conn = self._get_conn()
|
|
187
|
+
conn.execute(
|
|
188
|
+
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
|
189
|
+
(key, str(value)),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _get_meta(self, key: str, default: str = "") -> str:
|
|
193
|
+
conn = self._get_conn()
|
|
194
|
+
row = conn.execute(
|
|
195
|
+
"SELECT value FROM store_metadata WHERE key = ?", (key,),
|
|
196
|
+
).fetchone()
|
|
197
|
+
return row[0] if row else default
|
|
198
|
+
|
|
199
|
+
def _clear_rotation_meta(self) -> None:
|
|
200
|
+
conn = self._get_conn()
|
|
201
|
+
for key in _ROTATION_KEYS:
|
|
202
|
+
conn.execute("DELETE FROM store_metadata WHERE key = ?", (key,))
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Status
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def status(self) -> RotationStatus:
|
|
209
|
+
"""Read the current rotation state from ``store_metadata``."""
|
|
210
|
+
if self._conn is None:
|
|
211
|
+
return RotationStatus(
|
|
212
|
+
state=RotationState.NONE,
|
|
213
|
+
from_model="", from_version="", from_dim=0,
|
|
214
|
+
to_model="", to_version="", to_dim=0,
|
|
215
|
+
started_at=0.0,
|
|
216
|
+
processed_rows=0, total_rows=0,
|
|
217
|
+
last_error="vectors.db not found",
|
|
218
|
+
)
|
|
219
|
+
state_raw = self._get_meta("rotation_state", RotationState.NONE.value)
|
|
220
|
+
try:
|
|
221
|
+
state = RotationState(state_raw)
|
|
222
|
+
except ValueError:
|
|
223
|
+
state = RotationState.NONE
|
|
224
|
+
return RotationStatus(
|
|
225
|
+
state=state,
|
|
226
|
+
from_model=self._get_meta("rotation_from_model"),
|
|
227
|
+
from_version=self._get_meta("rotation_from_version"),
|
|
228
|
+
from_dim=int(self._get_meta("rotation_from_dim") or "0"),
|
|
229
|
+
to_model=self._get_meta("rotation_to_model"),
|
|
230
|
+
to_version=self._get_meta("rotation_to_version"),
|
|
231
|
+
to_dim=int(self._get_meta("rotation_to_dim") or "0"),
|
|
232
|
+
started_at=float(self._get_meta("rotation_started_at") or "0"),
|
|
233
|
+
processed_rows=int(self._get_meta("rotation_processed_rows") or "0"),
|
|
234
|
+
total_rows=int(self._get_meta("rotation_total_rows") or "0"),
|
|
235
|
+
last_error=self._get_meta("rotation_last_error"),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# start
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def start(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
to_model: str,
|
|
246
|
+
to_version: str,
|
|
247
|
+
to_dim: int,
|
|
248
|
+
) -> RotationStatus:
|
|
249
|
+
"""Begin a new rotation.
|
|
250
|
+
|
|
251
|
+
Raises RuntimeError if a rotation is already active or if the
|
|
252
|
+
main ``vectors`` table is empty.
|
|
253
|
+
"""
|
|
254
|
+
conn = self._get_conn()
|
|
255
|
+
|
|
256
|
+
current = self.status()
|
|
257
|
+
if current.is_active():
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"rotation already active (state={current.state.value}); "
|
|
260
|
+
"abort it before starting a new one"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Capture the current "from" metadata from the active store_metadata.
|
|
264
|
+
from_dim = int(self._get_meta("dimension") or "0")
|
|
265
|
+
# "from" model is approximated from the most common model_name on
|
|
266
|
+
# existing rows. If the column was never populated (legacy rows
|
|
267
|
+
# from Phase 0), we fall back to "legacy".
|
|
268
|
+
from_model = self._dominant_model_name(conn) or "legacy"
|
|
269
|
+
from_version = self._dominant_model_version(conn)
|
|
270
|
+
|
|
271
|
+
row_count = conn.execute("SELECT COUNT(*) FROM vectors").fetchone()[0]
|
|
272
|
+
if row_count == 0:
|
|
273
|
+
raise RuntimeError("no vectors to rotate — table is empty")
|
|
274
|
+
|
|
275
|
+
# Clean any prior staging table from a failed rotation.
|
|
276
|
+
conn.execute(f"DROP TABLE IF EXISTS {STAGING_TABLE}")
|
|
277
|
+
# Mirror the vectors table schema. Using a literal CREATE keeps
|
|
278
|
+
# the PK + NOT NULL / DEFAULT constraints that a
|
|
279
|
+
# ``CREATE TABLE x AS SELECT`` would strip.
|
|
280
|
+
conn.execute(
|
|
281
|
+
f"""
|
|
282
|
+
CREATE TABLE {STAGING_TABLE} (
|
|
283
|
+
id TEXT PRIMARY KEY,
|
|
284
|
+
file_path TEXT NOT NULL,
|
|
285
|
+
chunk_type TEXT NOT NULL,
|
|
286
|
+
name TEXT NOT NULL,
|
|
287
|
+
text TEXT NOT NULL,
|
|
288
|
+
vector BLOB NOT NULL,
|
|
289
|
+
model_name TEXT NOT NULL DEFAULT '',
|
|
290
|
+
model_version TEXT NOT NULL DEFAULT '',
|
|
291
|
+
dimension INTEGER NOT NULL DEFAULT 0,
|
|
292
|
+
produced_at REAL NOT NULL DEFAULT 0,
|
|
293
|
+
blob_oid TEXT NOT NULL DEFAULT '',
|
|
294
|
+
action_hash TEXT NOT NULL DEFAULT ''
|
|
295
|
+
)
|
|
296
|
+
"""
|
|
297
|
+
)
|
|
298
|
+
# Matching indexes on the staging table so they're in place for
|
|
299
|
+
# search as soon as the cutover rename swap completes.
|
|
300
|
+
conn.execute(
|
|
301
|
+
f"CREATE INDEX IF NOT EXISTS idx_rot_vectors_file "
|
|
302
|
+
f"ON {STAGING_TABLE}(file_path)"
|
|
303
|
+
)
|
|
304
|
+
conn.execute(
|
|
305
|
+
f"CREATE INDEX IF NOT EXISTS idx_rot_vectors_model "
|
|
306
|
+
f"ON {STAGING_TABLE}(model_name, model_version)"
|
|
307
|
+
)
|
|
308
|
+
conn.execute(
|
|
309
|
+
f"CREATE INDEX IF NOT EXISTS idx_rot_vectors_blob "
|
|
310
|
+
f"ON {STAGING_TABLE}(blob_oid)"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
self._clear_rotation_meta()
|
|
314
|
+
self._set_meta("rotation_state", RotationState.PENDING.value)
|
|
315
|
+
self._set_meta("rotation_from_model", from_model)
|
|
316
|
+
self._set_meta("rotation_from_version", from_version)
|
|
317
|
+
self._set_meta("rotation_from_dim", str(from_dim))
|
|
318
|
+
self._set_meta("rotation_to_model", to_model)
|
|
319
|
+
self._set_meta("rotation_to_version", to_version)
|
|
320
|
+
self._set_meta("rotation_to_dim", str(to_dim))
|
|
321
|
+
self._set_meta("rotation_started_at", str(time.time()))
|
|
322
|
+
self._set_meta("rotation_cursor", "")
|
|
323
|
+
self._set_meta("rotation_processed_rows", "0")
|
|
324
|
+
self._set_meta("rotation_total_rows", str(row_count))
|
|
325
|
+
self._set_meta("rotation_last_error", "")
|
|
326
|
+
conn.commit()
|
|
327
|
+
|
|
328
|
+
return self.status()
|
|
329
|
+
|
|
330
|
+
def _dominant_model_name(self, conn: sqlite3.Connection) -> str:
|
|
331
|
+
"""Return the most common ``model_name`` in the vectors table.
|
|
332
|
+
|
|
333
|
+
Returns an empty string if no rows have a non-empty name (e.g.
|
|
334
|
+
a fresh v2 store where rows were inserted before provenance was
|
|
335
|
+
wired through).
|
|
336
|
+
"""
|
|
337
|
+
try:
|
|
338
|
+
row = conn.execute(
|
|
339
|
+
"SELECT model_name, COUNT(*) as c FROM vectors "
|
|
340
|
+
"WHERE model_name != '' "
|
|
341
|
+
"GROUP BY model_name ORDER BY c DESC LIMIT 1"
|
|
342
|
+
).fetchone()
|
|
343
|
+
except sqlite3.OperationalError:
|
|
344
|
+
return ""
|
|
345
|
+
return row[0] if row else ""
|
|
346
|
+
|
|
347
|
+
def _dominant_model_version(self, conn: sqlite3.Connection) -> str:
|
|
348
|
+
try:
|
|
349
|
+
row = conn.execute(
|
|
350
|
+
"SELECT model_version, COUNT(*) as c FROM vectors "
|
|
351
|
+
"WHERE model_version != '' "
|
|
352
|
+
"GROUP BY model_version ORDER BY c DESC LIMIT 1"
|
|
353
|
+
).fetchone()
|
|
354
|
+
except sqlite3.OperationalError:
|
|
355
|
+
return ""
|
|
356
|
+
return row[0] if row else ""
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
# step — advance the backfill
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
def step(
|
|
363
|
+
self,
|
|
364
|
+
provider: EmbeddingProvider,
|
|
365
|
+
batch_size: int = 32,
|
|
366
|
+
) -> int:
|
|
367
|
+
"""Embed one batch of rows with ``provider`` and write them to
|
|
368
|
+
the staging table.
|
|
369
|
+
|
|
370
|
+
Returns the number of rows processed. Returns 0 when the
|
|
371
|
+
rotation is done (state transitions to ``ready_to_cutover``).
|
|
372
|
+
|
|
373
|
+
Raises RuntimeError if there's no active rotation or if
|
|
374
|
+
``provider.dimension()`` doesn't match the target dim recorded
|
|
375
|
+
at ``start``.
|
|
376
|
+
"""
|
|
377
|
+
conn = self._get_conn()
|
|
378
|
+
status = self.status()
|
|
379
|
+
|
|
380
|
+
# Idempotent no-op once the backfill is already done — callers
|
|
381
|
+
# that drive step() in a while-loop would otherwise have to
|
|
382
|
+
# duplicate state-check logic.
|
|
383
|
+
if status.state == RotationState.READY_TO_CUTOVER:
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
if status.state not in (RotationState.PENDING, RotationState.BACKFILLING):
|
|
387
|
+
raise RuntimeError(
|
|
388
|
+
f"step() requires state pending|backfilling, got {status.state.value}"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
provider_dim = provider.dimension()
|
|
392
|
+
if provider_dim != status.to_dim:
|
|
393
|
+
self._set_meta(
|
|
394
|
+
"rotation_last_error",
|
|
395
|
+
f"provider dim {provider_dim} != target dim {status.to_dim}",
|
|
396
|
+
)
|
|
397
|
+
self._set_meta("rotation_state", RotationState.FAILED.value)
|
|
398
|
+
conn.commit()
|
|
399
|
+
raise RuntimeError(
|
|
400
|
+
f"provider {provider.name} has dim {provider_dim}, "
|
|
401
|
+
f"but rotation target dim is {status.to_dim}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
last_cursor = self._get_meta("rotation_cursor", "")
|
|
405
|
+
rows = conn.execute(
|
|
406
|
+
"SELECT id, file_path, chunk_type, name, text, blob_oid, action_hash "
|
|
407
|
+
"FROM vectors WHERE id > ? ORDER BY id LIMIT ?",
|
|
408
|
+
(last_cursor, batch_size),
|
|
409
|
+
).fetchall()
|
|
410
|
+
|
|
411
|
+
if not rows:
|
|
412
|
+
# Nothing left to process.
|
|
413
|
+
self._set_meta("rotation_state", RotationState.READY_TO_CUTOVER.value)
|
|
414
|
+
conn.commit()
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
# Promote to BACKFILLING on the first processed batch.
|
|
418
|
+
if status.state == RotationState.PENDING:
|
|
419
|
+
self._set_meta("rotation_state", RotationState.BACKFILLING.value)
|
|
420
|
+
|
|
421
|
+
texts = [r[4] for r in rows]
|
|
422
|
+
try:
|
|
423
|
+
new_vectors = provider.embed(texts)
|
|
424
|
+
except Exception as exc: # noqa: BLE001 — surface provider errors loudly
|
|
425
|
+
self._set_meta("rotation_last_error", f"embed failed: {exc}")
|
|
426
|
+
self._set_meta("rotation_state", RotationState.FAILED.value)
|
|
427
|
+
conn.commit()
|
|
428
|
+
raise
|
|
429
|
+
|
|
430
|
+
if len(new_vectors) != len(rows):
|
|
431
|
+
self._set_meta(
|
|
432
|
+
"rotation_last_error",
|
|
433
|
+
f"embed returned {len(new_vectors)} vectors for {len(rows)} inputs",
|
|
434
|
+
)
|
|
435
|
+
self._set_meta("rotation_state", RotationState.FAILED.value)
|
|
436
|
+
conn.commit()
|
|
437
|
+
raise RuntimeError("embed() length mismatch")
|
|
438
|
+
|
|
439
|
+
produced_at = time.time()
|
|
440
|
+
provider_name = provider.name # property, not callable
|
|
441
|
+
to_version = status.to_version
|
|
442
|
+
to_dim = status.to_dim
|
|
443
|
+
|
|
444
|
+
insert_sql = (
|
|
445
|
+
f"INSERT OR REPLACE INTO {STAGING_TABLE} "
|
|
446
|
+
"(id, file_path, chunk_type, name, text, vector, "
|
|
447
|
+
"model_name, model_version, dimension, produced_at, "
|
|
448
|
+
"blob_oid, action_hash) "
|
|
449
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
450
|
+
)
|
|
451
|
+
for (row, vec) in zip(rows, new_vectors, strict=True):
|
|
452
|
+
if len(vec) != to_dim:
|
|
453
|
+
self._set_meta(
|
|
454
|
+
"rotation_last_error",
|
|
455
|
+
f"provider returned vec of len {len(vec)}, expected {to_dim}",
|
|
456
|
+
)
|
|
457
|
+
self._set_meta("rotation_state", RotationState.FAILED.value)
|
|
458
|
+
conn.commit()
|
|
459
|
+
raise RuntimeError(
|
|
460
|
+
f"provider returned vec of wrong length: {len(vec)} != {to_dim}"
|
|
461
|
+
)
|
|
462
|
+
packed = struct.pack(f"{to_dim}f", *vec)
|
|
463
|
+
conn.execute(
|
|
464
|
+
insert_sql,
|
|
465
|
+
(
|
|
466
|
+
row[0], # id
|
|
467
|
+
row[1], # file_path
|
|
468
|
+
row[2], # chunk_type
|
|
469
|
+
row[3], # name
|
|
470
|
+
row[4], # text
|
|
471
|
+
packed, # new vector
|
|
472
|
+
provider_name, # model_name
|
|
473
|
+
to_version, # model_version
|
|
474
|
+
to_dim, # dimension
|
|
475
|
+
produced_at, # produced_at
|
|
476
|
+
row[5], # blob_oid (carried over)
|
|
477
|
+
row[6], # action_hash (carried over)
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
# Update cursor + processed count.
|
|
482
|
+
last_id = rows[-1][0]
|
|
483
|
+
processed = status.processed_rows + len(rows)
|
|
484
|
+
self._set_meta("rotation_cursor", last_id)
|
|
485
|
+
self._set_meta("rotation_processed_rows", str(processed))
|
|
486
|
+
conn.commit()
|
|
487
|
+
|
|
488
|
+
# If we drained the table, transition to ready_to_cutover.
|
|
489
|
+
if len(rows) < batch_size:
|
|
490
|
+
# We got fewer rows than requested, so the table is exhausted.
|
|
491
|
+
self._set_meta("rotation_state", RotationState.READY_TO_CUTOVER.value)
|
|
492
|
+
conn.commit()
|
|
493
|
+
|
|
494
|
+
return len(rows)
|
|
495
|
+
|
|
496
|
+
# ------------------------------------------------------------------
|
|
497
|
+
# cutover
|
|
498
|
+
# ------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
def cutover(self) -> RotationStatus:
|
|
501
|
+
"""Atomically swap ``vectors`` and ``vectors_rotating``.
|
|
502
|
+
|
|
503
|
+
The existing ``vectors`` becomes ``vectors_archive`` (still
|
|
504
|
+
present until ``gc_old``). Subsequent reads / writes hit the
|
|
505
|
+
new table. ``store_metadata.dimension`` is updated to the new
|
|
506
|
+
target dim.
|
|
507
|
+
|
|
508
|
+
Requires state ``ready_to_cutover``.
|
|
509
|
+
"""
|
|
510
|
+
conn = self._get_conn()
|
|
511
|
+
status = self.status()
|
|
512
|
+
if status.state != RotationState.READY_TO_CUTOVER:
|
|
513
|
+
raise RuntimeError(
|
|
514
|
+
f"cutover() requires state ready_to_cutover, got {status.state.value}"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
519
|
+
conn.execute(f"DROP TABLE IF EXISTS {ARCHIVE_TABLE}")
|
|
520
|
+
conn.execute(f"ALTER TABLE vectors RENAME TO {ARCHIVE_TABLE}")
|
|
521
|
+
conn.execute(f"ALTER TABLE {STAGING_TABLE} RENAME TO vectors")
|
|
522
|
+
# Update stored dimension pointer so next VectorStore open
|
|
523
|
+
# doesn't trip the mismatch check.
|
|
524
|
+
conn.execute(
|
|
525
|
+
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES ('dimension', ?)",
|
|
526
|
+
(str(status.to_dim),),
|
|
527
|
+
)
|
|
528
|
+
conn.execute(
|
|
529
|
+
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
|
530
|
+
("rotation_state", RotationState.CUTOVER_DONE.value),
|
|
531
|
+
)
|
|
532
|
+
# Invalidate the vector cache version so open VectorStores
|
|
533
|
+
# reload. Uses a monotonic bump stored in store_metadata.
|
|
534
|
+
prev_ver = int(self._get_meta("_rotator_cache_ver") or "0")
|
|
535
|
+
conn.execute(
|
|
536
|
+
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
|
537
|
+
("_rotator_cache_ver", str(prev_ver + 1)),
|
|
538
|
+
)
|
|
539
|
+
conn.commit()
|
|
540
|
+
except sqlite3.Error:
|
|
541
|
+
conn.execute("ROLLBACK")
|
|
542
|
+
self._set_meta("rotation_state", RotationState.FAILED.value)
|
|
543
|
+
self._set_meta("rotation_last_error", "cutover SQL failed")
|
|
544
|
+
conn.commit()
|
|
545
|
+
raise
|
|
546
|
+
|
|
547
|
+
return self.status()
|
|
548
|
+
|
|
549
|
+
# ------------------------------------------------------------------
|
|
550
|
+
# gc_old
|
|
551
|
+
# ------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
def gc_old(self) -> RotationStatus:
|
|
554
|
+
"""Drop the archive table and clear rotation metadata.
|
|
555
|
+
|
|
556
|
+
Requires state ``cutover_done``. Transitions through ``gc_done``
|
|
557
|
+
back to ``none``.
|
|
558
|
+
"""
|
|
559
|
+
conn = self._get_conn()
|
|
560
|
+
status = self.status()
|
|
561
|
+
if status.state != RotationState.CUTOVER_DONE:
|
|
562
|
+
raise RuntimeError(
|
|
563
|
+
f"gc_old() requires state cutover_done, got {status.state.value}"
|
|
564
|
+
)
|
|
565
|
+
conn.execute(f"DROP TABLE IF EXISTS {ARCHIVE_TABLE}")
|
|
566
|
+
self._set_meta("rotation_state", RotationState.GC_DONE.value)
|
|
567
|
+
conn.commit()
|
|
568
|
+
# Then clean the rotation metadata entirely so state goes back to NONE.
|
|
569
|
+
self._clear_rotation_meta()
|
|
570
|
+
conn.commit()
|
|
571
|
+
return self.status()
|
|
572
|
+
|
|
573
|
+
# ------------------------------------------------------------------
|
|
574
|
+
# abort
|
|
575
|
+
# ------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
def abort(self) -> RotationStatus:
|
|
578
|
+
"""Cancel a pre-cutover rotation.
|
|
579
|
+
|
|
580
|
+
Drops ``vectors_rotating`` and clears rotation metadata. The
|
|
581
|
+
primary ``vectors`` table is untouched. Legal from any state
|
|
582
|
+
other than ``cutover_done`` / ``gc_done``.
|
|
583
|
+
"""
|
|
584
|
+
conn = self._get_conn()
|
|
585
|
+
status = self.status()
|
|
586
|
+
if status.state in (RotationState.CUTOVER_DONE, RotationState.GC_DONE):
|
|
587
|
+
raise RuntimeError(
|
|
588
|
+
f"abort() would corrupt state {status.state.value}; "
|
|
589
|
+
"use gc_old instead or restore from a snapshot"
|
|
590
|
+
)
|
|
591
|
+
conn.execute(f"DROP TABLE IF EXISTS {STAGING_TABLE}")
|
|
592
|
+
self._set_meta("rotation_state", RotationState.ABORTED.value)
|
|
593
|
+
conn.commit()
|
|
594
|
+
# Clear metadata back to NONE so a fresh rotation can begin.
|
|
595
|
+
self._clear_rotation_meta()
|
|
596
|
+
conn.commit()
|
|
597
|
+
return self.status()
|