superlocalmemory 3.6.22 → 3.7.0
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.
- package/CHANGELOG.md +52 -0
- package/README.md +275 -72
- package/bin/slm-npm +43 -89
- package/docs/pi-dev-integration.md +43 -0
- package/ide/configs/antigravity-mcp.json +2 -2
- package/ide/configs/chatgpt-desktop-mcp.json +1 -1
- package/ide/configs/claude-desktop-mcp.json +2 -2
- package/ide/configs/windsurf-mcp.json +2 -2
- package/ide/hooks/context-hook.js +6 -2
- package/ide/hooks/post-recall-hook.js +7 -3
- package/ide/hooks/tool-event-hook.sh +2 -1
- package/package.json +19 -10
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/_GENERATED.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +40 -8
- package/scripts/postinstall-interactive.js +17 -94
- package/scripts/postinstall.js +185 -258
- package/scripts/preuninstall.js +9 -50
- package/src/superlocalmemory/__init__.py +2 -2
- package/src/superlocalmemory/attribution/mathematical_dna.py +1 -1
- package/src/superlocalmemory/attribution/signer.py +34 -19
- package/src/superlocalmemory/attribution/watermark.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +3 -5
- package/src/superlocalmemory/cli/commands.py +490 -195
- package/src/superlocalmemory/cli/context_commands.py +5 -4
- package/src/superlocalmemory/cli/daemon.py +282 -187
- package/src/superlocalmemory/cli/db_migrate.py +3 -1
- package/src/superlocalmemory/cli/diagnostics_cmd.py +28 -0
- package/src/superlocalmemory/cli/evidence_cmd.py +103 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +7 -3
- package/src/superlocalmemory/cli/main.py +128 -31
- package/src/superlocalmemory/cli/pending_store.py +54 -38
- package/src/superlocalmemory/cli/scale_engine_cmd.py +37 -0
- package/src/superlocalmemory/cli/service_installer.py +57 -52
- package/src/superlocalmemory/cli/setup_wizard.py +142 -88
- package/src/superlocalmemory/cli/version_banner.py +2 -1
- package/src/superlocalmemory/code_graph/config.py +3 -1
- package/src/superlocalmemory/core/backend_orchestrator.py +81 -21
- package/src/superlocalmemory/core/config.py +65 -20
- package/src/superlocalmemory/core/consolidation_engine.py +9 -7
- package/src/superlocalmemory/core/context_cache.py +56 -8
- package/src/superlocalmemory/core/derivation_lineage.py +246 -0
- package/src/superlocalmemory/core/embedding_worker.py +32 -20
- package/src/superlocalmemory/core/embeddings.py +54 -18
- package/src/superlocalmemory/core/engine.py +150 -104
- package/src/superlocalmemory/core/engine_ingestion.py +513 -0
- package/src/superlocalmemory/core/engine_wiring.py +2 -0
- package/src/superlocalmemory/core/evidence_bundle.py +526 -0
- package/src/superlocalmemory/core/fact_consolidator.py +5 -11
- package/src/superlocalmemory/core/graph_analyzer.py +2 -2
- package/src/superlocalmemory/core/health_monitor.py +4 -2
- package/src/superlocalmemory/core/ingestion_command.py +636 -0
- package/src/superlocalmemory/core/injection.py +69 -18
- package/src/superlocalmemory/core/lifecycle_state.py +153 -0
- package/src/superlocalmemory/core/maintenance.py +23 -22
- package/src/superlocalmemory/core/maintenance_scheduler.py +51 -35
- package/src/superlocalmemory/core/mutations.py +143 -0
- package/src/superlocalmemory/core/platform_utils.py +7 -4
- package/src/superlocalmemory/core/ram_lock.py +16 -5
- package/src/superlocalmemory/core/rate_limit.py +1 -1
- package/src/superlocalmemory/core/recall_pipeline.py +60 -101
- package/src/superlocalmemory/core/recall_worker.py +76 -59
- package/src/superlocalmemory/core/registry.py +1 -1
- package/src/superlocalmemory/core/scale_engine.py +293 -0
- package/src/superlocalmemory/core/score_contract.py +62 -0
- package/src/superlocalmemory/core/security_primitives.py +3 -1
- package/src/superlocalmemory/core/slm_disabled.py +3 -5
- package/src/superlocalmemory/core/store_pipeline.py +172 -40
- package/src/superlocalmemory/core/tier_manager.py +32 -20
- package/src/superlocalmemory/core/worker_pool.py +13 -4
- package/src/superlocalmemory/dynamics/activation_guided_quantization.py +1 -1
- package/src/superlocalmemory/dynamics/eap_scheduler.py +10 -3
- package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +1 -1
- package/src/superlocalmemory/dynamics/fisher_langevin_coupling.py +1 -1
- package/src/superlocalmemory/encoding/auto_linker.py +1 -1
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +7 -16
- package/src/superlocalmemory/encoding/consolidator.py +22 -5
- package/src/superlocalmemory/encoding/fact_extractor.py +1 -1
- package/src/superlocalmemory/encoding/foresight.py +2 -0
- package/src/superlocalmemory/encoding/graph_builder.py +1 -1
- package/src/superlocalmemory/encoding/temporal_parser.py +2 -0
- package/src/superlocalmemory/evaluation/__init__.py +13 -0
- package/src/superlocalmemory/evaluation/calibration.py +308 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +2 -1
- package/src/superlocalmemory/graph/cozo_backend.py +256 -23
- package/src/superlocalmemory/hooks/_outcome_common.py +21 -11
- package/src/superlocalmemory/hooks/antigravity_adapter.py +10 -31
- package/src/superlocalmemory/hooks/auto_invoker.py +25 -27
- package/src/superlocalmemory/hooks/auto_recall.py +31 -6
- package/src/superlocalmemory/hooks/auto_recall_hook.py +13 -33
- package/src/superlocalmemory/hooks/before_web_hook.py +9 -7
- package/src/superlocalmemory/hooks/claude_code_hooks.py +126 -39
- package/src/superlocalmemory/hooks/codex_assets.py +59 -0
- package/src/superlocalmemory/hooks/codex_hooks.py +186 -0
- package/src/superlocalmemory/hooks/context_payload.py +1 -1
- package/src/superlocalmemory/hooks/copilot_adapter.py +9 -24
- package/src/superlocalmemory/hooks/cursor_adapter.py +10 -32
- package/src/superlocalmemory/hooks/hook_daemon.py +4 -2
- package/src/superlocalmemory/hooks/hook_handlers.py +241 -55
- package/src/superlocalmemory/hooks/memory_protocol.py +5 -3
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +4 -2
- package/src/superlocalmemory/hooks/session_registry.py +15 -8
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +10 -6
- package/src/superlocalmemory/hooks/topic_shift_hook.py +42 -12
- package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -14
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +19 -11
- package/src/superlocalmemory/infra/auth_middleware.py +38 -5
- package/src/superlocalmemory/infra/backup.py +7 -5
- package/src/superlocalmemory/infra/cloud_backup.py +18 -8
- package/src/superlocalmemory/infra/daemon_identity.py +248 -0
- package/src/superlocalmemory/infra/data_root.py +199 -0
- package/src/superlocalmemory/infra/event_bus.py +3 -1
- package/src/superlocalmemory/infra/local_diagnostics.py +327 -0
- package/src/superlocalmemory/infra/process_reaper.py +23 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +27 -9
- package/src/superlocalmemory/ingestion/base_adapter.py +25 -31
- package/src/superlocalmemory/ingestion/calendar_adapter.py +13 -4
- package/src/superlocalmemory/ingestion/credentials.py +14 -7
- package/src/superlocalmemory/ingestion/gmail_adapter.py +13 -4
- package/src/superlocalmemory/ingestion/transcript_adapter.py +7 -2
- package/src/superlocalmemory/learning/consolidation_quantization_worker.py +1 -1
- package/src/superlocalmemory/learning/ensemble.py +11 -0
- package/src/superlocalmemory/learning/entity_compiler.py +1 -1
- package/src/superlocalmemory/learning/feedback.py +1 -1
- package/src/superlocalmemory/learning/forgetting_scheduler.py +12 -7
- package/src/superlocalmemory/learning/quantization_scheduler.py +1 -1
- package/src/superlocalmemory/learning/ranker.py +4 -1
- package/src/superlocalmemory/learning/source_quality.py +1 -1
- package/src/superlocalmemory/learning/trigram_index.py +3 -2
- package/src/superlocalmemory/llm/backbone.py +13 -8
- package/src/superlocalmemory/math/ebbinghaus.py +1 -1
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/fisher_quantized.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +1 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/polar_quant.py +3 -4
- package/src/superlocalmemory/math/qjl.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +1 -1
- package/src/superlocalmemory/math/turbo_quant.py +3 -2
- package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -11
- package/src/superlocalmemory/mcp/_pool_adapter.py +27 -0
- package/src/superlocalmemory/mcp/http_transport.py +53 -0
- package/src/superlocalmemory/mcp/server.py +39 -13
- package/src/superlocalmemory/mcp/shared.py +69 -3
- package/src/superlocalmemory/mcp/tools_active.py +141 -31
- package/src/superlocalmemory/mcp/tools_core.py +128 -29
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -7
- package/src/superlocalmemory/mcp/tools_learning.py +42 -2
- package/src/superlocalmemory/mcp/tools_mesh.py +7 -23
- package/src/superlocalmemory/mcp/tools_optimize.py +8 -1
- package/src/superlocalmemory/mcp/tools_v28.py +23 -2
- package/src/superlocalmemory/mcp/tools_v3.py +26 -1
- package/src/superlocalmemory/mcp/tools_v33.py +56 -17
- package/src/superlocalmemory/mesh/broker.py +2 -0
- package/src/superlocalmemory/mesh/remote_sync.py +50 -12
- package/src/superlocalmemory/optimize/cache/manager.py +77 -1
- package/src/superlocalmemory/optimize/cache/semantic.py +23 -3
- package/src/superlocalmemory/optimize/compress/ccr.py +4 -0
- package/src/superlocalmemory/optimize/compress/router.py +6 -1
- package/src/superlocalmemory/optimize/config/__init__.py +5 -0
- package/src/superlocalmemory/optimize/config/store.py +6 -4
- package/src/superlocalmemory/optimize/proxy/_helpers.py +15 -5
- package/src/superlocalmemory/optimize/proxy/capture.py +3 -2
- package/src/superlocalmemory/optimize/proxy/server.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +14 -13
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +35 -11
- package/src/superlocalmemory/retrieval/bridge_discovery.py +73 -8
- package/src/superlocalmemory/retrieval/engine.py +169 -79
- package/src/superlocalmemory/retrieval/entity_channel.py +289 -67
- package/src/superlocalmemory/retrieval/forgetting_filter.py +1 -1
- package/src/superlocalmemory/retrieval/fusion.py +1 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +118 -30
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +16 -10
- package/src/superlocalmemory/retrieval/reranker.py +56 -20
- package/src/superlocalmemory/retrieval/scope_policy.py +85 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +122 -14
- package/src/superlocalmemory/retrieval/spreading_activation.py +141 -25
- package/src/superlocalmemory/retrieval/strategy.py +1 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +30 -15
- package/src/superlocalmemory/retrieval/vector_store.py +1 -1
- package/src/superlocalmemory/server/api.py +10 -7
- package/src/superlocalmemory/server/bandit_loops.py +4 -2
- package/src/superlocalmemory/server/recall_serializer.py +24 -0
- package/src/superlocalmemory/server/route_mutations.py +84 -0
- package/src/superlocalmemory/server/routes/agents.py +8 -6
- package/src/superlocalmemory/server/routes/brain.py +14 -12
- package/src/superlocalmemory/server/routes/chat.py +29 -12
- package/src/superlocalmemory/server/routes/data_io.py +55 -24
- package/src/superlocalmemory/server/routes/helpers.py +29 -4
- package/src/superlocalmemory/server/routes/ingest.py +53 -36
- package/src/superlocalmemory/server/routes/memories.py +104 -43
- package/src/superlocalmemory/server/routes/mesh.py +31 -0
- package/src/superlocalmemory/server/routes/profiles.py +26 -4
- package/src/superlocalmemory/server/routes/tiers.py +43 -11
- package/src/superlocalmemory/server/routes/timeline.py +5 -1
- package/src/superlocalmemory/server/routes/v3_api.py +76 -21
- package/src/superlocalmemory/server/security_middleware.py +1 -1
- package/src/superlocalmemory/server/ui.py +6 -3
- package/src/superlocalmemory/server/unified_daemon.py +680 -293
- package/src/superlocalmemory/server/write_identity.py +147 -0
- package/src/superlocalmemory/storage/access_log.py +4 -3
- package/src/superlocalmemory/storage/database.py +118 -25
- package/src/superlocalmemory/storage/migration_runner.py +84 -1
- package/src/superlocalmemory/storage/migration_v33.py +1 -1
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +6 -60
- package/src/superlocalmemory/storage/migrations/M018_ingestion_operations.py +120 -0
- package/src/superlocalmemory/storage/migrations/M019_derivation_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M020_model_state_integrity.py +52 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +5 -0
- package/src/superlocalmemory/storage/models.py +16 -0
- package/src/superlocalmemory/storage/quantized_store.py +20 -3
- package/src/superlocalmemory/storage/v2_migrator.py +5 -3
- package/src/superlocalmemory/ui/favicon.svg +5 -0
- package/src/superlocalmemory/ui/index.html +1 -0
- package/src/superlocalmemory/ui/js/compliance.js +1 -1
- package/src/superlocalmemory/ui/js/core.js +49 -8
- package/src/superlocalmemory/ui/js/dashboard.js +23 -2
- package/src/superlocalmemory/ui/js/feedback.js +1 -1
- package/src/superlocalmemory/ui/js/graph-filters.js +1 -1
- package/src/superlocalmemory/ui/js/graph-ui.js +1 -1
- package/src/superlocalmemory/ui/js/lifecycle.js +1 -1
- package/src/superlocalmemory/ui/js/ng-mesh.js +15 -49
- package/src/superlocalmemory/ui/js/settings.js +4 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +57 -9
- package/bin/slm +0 -59
- package/bin/slm.bat +0 -77
- package/bin/slm.cmd +0 -5
- package/ide/integrations/langchain/README.md +0 -106
- package/ide/integrations/langchain/langchain_superlocalmemory/__init__.py +0 -9
- package/ide/integrations/langchain/langchain_superlocalmemory/chat_message_history.py +0 -201
- package/ide/integrations/langchain/pyproject.toml +0 -38
- package/ide/integrations/langchain/tests/__init__.py +0 -3
- package/ide/integrations/langchain/tests/test_chat_message_history.py +0 -215
- package/ide/integrations/langchain/tests/test_security.py +0 -117
- package/ide/integrations/llamaindex/README.md +0 -81
- package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/__init__.py +0 -9
- package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/base.py +0 -316
- package/ide/integrations/llamaindex/pyproject.toml +0 -43
- package/ide/integrations/llamaindex/tests/__init__.py +0 -3
- package/ide/integrations/llamaindex/tests/test_chat_store.py +0 -294
- package/ide/integrations/llamaindex/tests/test_security.py +0 -241
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-memory-advisor.md +0 -44
- package/plugin-src/agents/slm-optimize-advisor.md +0 -38
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -23
- package/plugin-src/manifest.json +0 -25
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -15
- package/plugin-src/scripts/slm-launch.bat +0 -17
- package/plugin-src/settings.json +0 -16
- package/plugin-src/skills/slm-cache/SKILL.md +0 -140
- package/plugin-src/skills/slm-compress/SKILL.md +0 -143
- package/plugin-src/skills/slm-graph/SKILL.md +0 -300
- package/plugin-src/skills/slm-recall/SKILL.md +0 -204
- package/plugin-src/skills/slm-remember/SKILL.md +0 -194
- package/plugin-src/skills/slm-session/SKILL.md +0 -207
- package/plugin-src/skills/slm-status/SKILL.md +0 -149
- package/scripts/__tests__/build-plugin.test.mjs +0 -613
- package/scripts/_savings_math.py +0 -270
- package/scripts/build-dmg.sh +0 -417
- package/scripts/build-plugin.js +0 -742
- package/scripts/build-slm-hook.ps1 +0 -40
- package/scripts/build-slm-hook.sh +0 -45
- package/scripts/build_entry.py +0 -452
- package/scripts/ci/stage5b_gate.sh +0 -50
- package/scripts/dogfood_savings.py +0 -490
- package/scripts/generate-thumbnails.py +0 -218
- package/scripts/install-skills.ps1 +0 -4
- package/scripts/install-skills.sh +0 -5
- package/scripts/install.ps1 +0 -701
- package/scripts/install.sh +0 -1015
- package/scripts/postinstall_binary.js +0 -287
- package/scripts/prepack.js +0 -33
- package/scripts/release_manifest.py +0 -273
- package/scripts/slm-hook.spec +0 -56
- package/scripts/start-dashboard.ps1 +0 -52
- package/scripts/start-dashboard.sh +0 -41
- package/scripts/sync-wiki.ps1 +0 -127
- package/scripts/sync-wiki.sh +0 -82
- package/scripts/test-dmg.sh +0 -161
- package/scripts/test-npm-package.ps1 +0 -252
- package/scripts/test-npm-package.sh +0 -207
- package/scripts/verify-install.ps1 +0 -294
- package/scripts/verify-install.sh +0 -266
- package/scripts/verify-v27.ps1 +0 -301
- package/scripts/verify-v27.sh +0 -233
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -513
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -529
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -71
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3
|
|
4
|
+
|
|
5
|
+
"""Canonical durable-ingestion command and operation state machine.
|
|
6
|
+
|
|
7
|
+
The command deliberately depends on injected queryable/materialization
|
|
8
|
+
functions. This keeps the durable contract testable while legacy write paths
|
|
9
|
+
are migrated through an expand-migrate-contract rollout.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
25
|
+
|
|
26
|
+
_MATERIALIZATION_LOCKS = tuple(threading.RLock() for _ in range(64))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _materialization_lock(operation_id: str) -> threading.RLock:
|
|
30
|
+
bucket = int(hashlib.sha256(operation_id.encode("utf-8")).hexdigest()[:8], 16)
|
|
31
|
+
return _MATERIALIZATION_LOCKS[bucket % len(_MATERIALIZATION_LOCKS)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IngestionState(str, Enum):
|
|
35
|
+
RAW = "raw"
|
|
36
|
+
QUERYABLE = "queryable"
|
|
37
|
+
ENRICHING = "enriching"
|
|
38
|
+
COMPLETE = "complete"
|
|
39
|
+
FAILED = "failed"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class IdempotencyConflict(ValueError):
|
|
43
|
+
"""The same idempotency key was reused for different immutable evidence."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InvalidStateTransition(RuntimeError):
|
|
47
|
+
"""An ingestion operation attempted an illegal or stale transition."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OperationInProgress(RuntimeError):
|
|
51
|
+
"""Another live lease owner is materializing this operation."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _canonical_json(value: Any) -> str:
|
|
55
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class IngestionRequest:
|
|
60
|
+
content: str
|
|
61
|
+
profile_id: str
|
|
62
|
+
source_type: str
|
|
63
|
+
idempotency_key: str
|
|
64
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
scope: str = "personal"
|
|
66
|
+
shared_with: tuple[str, ...] = ()
|
|
67
|
+
trusted_actor_id: str = ""
|
|
68
|
+
session_id: str = ""
|
|
69
|
+
session_date: str = ""
|
|
70
|
+
speaker: str = ""
|
|
71
|
+
role: str = "user"
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if not self.content or not self.content.strip():
|
|
75
|
+
raise ValueError("content is required")
|
|
76
|
+
for name in ("profile_id", "source_type", "idempotency_key"):
|
|
77
|
+
if not str(getattr(self, name)).strip():
|
|
78
|
+
raise ValueError(f"{name} is required")
|
|
79
|
+
if self.scope not in {"personal", "project", "shared", "global"}:
|
|
80
|
+
raise ValueError(f"unsupported scope: {self.scope}")
|
|
81
|
+
object.__setattr__(self, "metadata", dict(self.metadata))
|
|
82
|
+
object.__setattr__(self, "shared_with", tuple(self.shared_with))
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def source_hash(self) -> str:
|
|
86
|
+
return hashlib.sha256(self.content.encode("utf-8")).hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class IngestionOperation:
|
|
91
|
+
operation_id: str
|
|
92
|
+
profile_id: str
|
|
93
|
+
source_type: str
|
|
94
|
+
idempotency_key: str
|
|
95
|
+
source_hash: str
|
|
96
|
+
raw_content: str
|
|
97
|
+
metadata: dict[str, Any]
|
|
98
|
+
scope: str
|
|
99
|
+
shared_with: tuple[str, ...]
|
|
100
|
+
trusted_actor_id: str
|
|
101
|
+
session_id: str
|
|
102
|
+
session_date: str
|
|
103
|
+
speaker: str
|
|
104
|
+
role: str
|
|
105
|
+
state: IngestionState
|
|
106
|
+
queryable_fact_ids: tuple[str, ...]
|
|
107
|
+
final_fact_ids: tuple[str, ...]
|
|
108
|
+
derivation_version: str
|
|
109
|
+
derivation_state: dict[str, bool]
|
|
110
|
+
lease_owner: str
|
|
111
|
+
lease_expires_at: float
|
|
112
|
+
next_retry_at: float
|
|
113
|
+
attempt_count: int
|
|
114
|
+
last_error: str
|
|
115
|
+
created_at: str
|
|
116
|
+
updated_at: str
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def fact_ids(self) -> tuple[str, ...]:
|
|
120
|
+
return self.final_fact_ids or self.queryable_fact_ids
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class IngestionOperationRepository:
|
|
124
|
+
"""Persistence and compare-and-swap transitions for M018 operations."""
|
|
125
|
+
|
|
126
|
+
_ALLOWED: dict[IngestionState, frozenset[IngestionState]] = {
|
|
127
|
+
IngestionState.RAW: frozenset(
|
|
128
|
+
{IngestionState.QUERYABLE, IngestionState.FAILED}
|
|
129
|
+
),
|
|
130
|
+
IngestionState.QUERYABLE: frozenset(
|
|
131
|
+
{IngestionState.ENRICHING, IngestionState.FAILED}
|
|
132
|
+
),
|
|
133
|
+
IngestionState.ENRICHING: frozenset(
|
|
134
|
+
{IngestionState.COMPLETE, IngestionState.FAILED}
|
|
135
|
+
),
|
|
136
|
+
IngestionState.FAILED: frozenset({IngestionState.ENRICHING}),
|
|
137
|
+
IngestionState.COMPLETE: frozenset(),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def __init__(self, db: DatabaseManager) -> None:
|
|
141
|
+
self.db = db
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _from_row(row: Any) -> IngestionOperation:
|
|
145
|
+
data = dict(row)
|
|
146
|
+
return IngestionOperation(
|
|
147
|
+
operation_id=data["operation_id"],
|
|
148
|
+
profile_id=data["profile_id"],
|
|
149
|
+
source_type=data["source_type"],
|
|
150
|
+
idempotency_key=data["idempotency_key"],
|
|
151
|
+
source_hash=data["source_hash"],
|
|
152
|
+
raw_content=data["raw_content"],
|
|
153
|
+
metadata=json.loads(data["raw_metadata_json"] or "{}"),
|
|
154
|
+
scope=data["scope"],
|
|
155
|
+
shared_with=tuple(json.loads(data["shared_with_json"] or "[]")),
|
|
156
|
+
trusted_actor_id=data["trusted_actor_id"],
|
|
157
|
+
session_id=data["session_id"],
|
|
158
|
+
session_date=data["session_date"],
|
|
159
|
+
speaker=data["speaker"],
|
|
160
|
+
role=data["role"],
|
|
161
|
+
state=IngestionState(data["state"]),
|
|
162
|
+
queryable_fact_ids=tuple(
|
|
163
|
+
json.loads(data["queryable_fact_ids_json"] or "[]")
|
|
164
|
+
),
|
|
165
|
+
final_fact_ids=tuple(json.loads(data["final_fact_ids_json"] or "[]")),
|
|
166
|
+
derivation_version=data["derivation_version"],
|
|
167
|
+
derivation_state=json.loads(data.get("derivation_state_json") or "{}"),
|
|
168
|
+
lease_owner=data.get("lease_owner") or "",
|
|
169
|
+
lease_expires_at=float(data.get("lease_expires_at") or 0),
|
|
170
|
+
next_retry_at=float(data.get("next_retry_at") or 0),
|
|
171
|
+
attempt_count=int(data["attempt_count"]),
|
|
172
|
+
last_error=data["last_error"],
|
|
173
|
+
created_at=data["created_at"],
|
|
174
|
+
updated_at=data["updated_at"],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _find_request(self, request: IngestionRequest) -> IngestionOperation | None:
|
|
178
|
+
rows = self.db.execute(
|
|
179
|
+
"SELECT * FROM ingestion_operations "
|
|
180
|
+
"WHERE profile_id=? AND source_type=? AND idempotency_key=?",
|
|
181
|
+
(request.profile_id, request.source_type, request.idempotency_key),
|
|
182
|
+
)
|
|
183
|
+
return self._from_row(rows[0]) if rows else None
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _assert_same_request(
|
|
187
|
+
existing: IngestionOperation, request: IngestionRequest
|
|
188
|
+
) -> None:
|
|
189
|
+
comparable = (
|
|
190
|
+
existing.source_hash == request.source_hash,
|
|
191
|
+
existing.raw_content == request.content,
|
|
192
|
+
existing.metadata == request.metadata,
|
|
193
|
+
existing.scope == request.scope,
|
|
194
|
+
existing.shared_with == request.shared_with,
|
|
195
|
+
existing.trusted_actor_id == request.trusted_actor_id,
|
|
196
|
+
existing.session_id == request.session_id,
|
|
197
|
+
existing.session_date == request.session_date,
|
|
198
|
+
existing.speaker == request.speaker,
|
|
199
|
+
existing.role == request.role,
|
|
200
|
+
)
|
|
201
|
+
if not all(comparable):
|
|
202
|
+
raise IdempotencyConflict(
|
|
203
|
+
"idempotency key already belongs to different immutable evidence"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def create(self, request: IngestionRequest) -> IngestionOperation:
|
|
207
|
+
operation, _created = self.create_with_status(request)
|
|
208
|
+
return operation
|
|
209
|
+
|
|
210
|
+
def create_with_status(
|
|
211
|
+
self, request: IngestionRequest,
|
|
212
|
+
) -> tuple[IngestionOperation, bool]:
|
|
213
|
+
existing = self._find_request(request)
|
|
214
|
+
if existing is not None:
|
|
215
|
+
self._assert_same_request(existing, request)
|
|
216
|
+
return existing, False
|
|
217
|
+
|
|
218
|
+
operation_id = uuid.uuid4().hex
|
|
219
|
+
try:
|
|
220
|
+
self.db.execute(
|
|
221
|
+
"INSERT INTO ingestion_operations "
|
|
222
|
+
"(operation_id, profile_id, source_type, idempotency_key, "
|
|
223
|
+
"source_hash, raw_content, raw_metadata_json, scope, "
|
|
224
|
+
"shared_with_json, trusted_actor_id, session_id, session_date, "
|
|
225
|
+
"speaker, role) "
|
|
226
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
227
|
+
(
|
|
228
|
+
operation_id,
|
|
229
|
+
request.profile_id,
|
|
230
|
+
request.source_type,
|
|
231
|
+
request.idempotency_key,
|
|
232
|
+
request.source_hash,
|
|
233
|
+
request.content,
|
|
234
|
+
_canonical_json(request.metadata),
|
|
235
|
+
request.scope,
|
|
236
|
+
_canonical_json(list(request.shared_with)),
|
|
237
|
+
request.trusted_actor_id,
|
|
238
|
+
request.session_id,
|
|
239
|
+
request.session_date,
|
|
240
|
+
request.speaker,
|
|
241
|
+
request.role,
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
except sqlite3.IntegrityError:
|
|
245
|
+
concurrent = self._find_request(request)
|
|
246
|
+
if concurrent is None:
|
|
247
|
+
raise
|
|
248
|
+
self._assert_same_request(concurrent, request)
|
|
249
|
+
return concurrent, False
|
|
250
|
+
return self.get(operation_id), True
|
|
251
|
+
|
|
252
|
+
def get(self, operation_id: str) -> IngestionOperation:
|
|
253
|
+
rows = self.db.execute(
|
|
254
|
+
"SELECT * FROM ingestion_operations WHERE operation_id=?",
|
|
255
|
+
(operation_id,),
|
|
256
|
+
)
|
|
257
|
+
if not rows:
|
|
258
|
+
raise KeyError(operation_id)
|
|
259
|
+
return self._from_row(rows[0])
|
|
260
|
+
|
|
261
|
+
def list_operations(self) -> list[IngestionOperation]:
|
|
262
|
+
return [
|
|
263
|
+
self._from_row(row)
|
|
264
|
+
for row in self.db.execute(
|
|
265
|
+
"SELECT * FROM ingestion_operations ORDER BY created_at, operation_id"
|
|
266
|
+
)
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
def list_materializable(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
limit: int = 50,
|
|
273
|
+
min_queryable_age_seconds: float = 0.0,
|
|
274
|
+
) -> list[IngestionOperation]:
|
|
275
|
+
"""Return durable work in FIFO order for the background materializer.
|
|
276
|
+
|
|
277
|
+
A short age gate can protect a freshly admitted receipt from racing
|
|
278
|
+
the user's immediate recall on single-queue local model runtimes such
|
|
279
|
+
as Ollama. Failed retries and expired leases remain immediately due.
|
|
280
|
+
"""
|
|
281
|
+
now = time.time()
|
|
282
|
+
grace_modifier = f"-{max(0.0, float(min_queryable_age_seconds))} seconds"
|
|
283
|
+
return [
|
|
284
|
+
self._from_row(row)
|
|
285
|
+
for row in self.db.execute(
|
|
286
|
+
"SELECT * FROM ingestion_operations "
|
|
287
|
+
"WHERE (state='queryable' AND created_at <= "
|
|
288
|
+
"strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) "
|
|
289
|
+
"OR (state='failed' AND next_retry_at <= ?) "
|
|
290
|
+
"OR (state='enriching' AND lease_expires_at <= ?) "
|
|
291
|
+
"ORDER BY created_at, rowid LIMIT ?",
|
|
292
|
+
(grace_modifier, now, now, max(1, int(limit))),
|
|
293
|
+
)
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
def claim_enriching(
|
|
297
|
+
self,
|
|
298
|
+
operation_id: str,
|
|
299
|
+
*,
|
|
300
|
+
owner: str,
|
|
301
|
+
lease_seconds: float,
|
|
302
|
+
) -> IngestionOperation:
|
|
303
|
+
"""Claim queryable/failed work or reclaim an expired enrichment."""
|
|
304
|
+
now = time.time()
|
|
305
|
+
rows = self.db.execute(
|
|
306
|
+
"UPDATE ingestion_operations SET state='enriching', "
|
|
307
|
+
"lease_owner=?, lease_expires_at=?, "
|
|
308
|
+
"next_retry_at=0, attempt_count=attempt_count + "
|
|
309
|
+
"CASE WHEN state='enriching' AND lease_owner=? THEN 0 ELSE 1 END, "
|
|
310
|
+
"last_error='', "
|
|
311
|
+
"updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
|
|
312
|
+
"WHERE operation_id=? AND ("
|
|
313
|
+
"state IN ('queryable', 'failed') OR "
|
|
314
|
+
"(state='enriching' AND (lease_owner=? OR lease_expires_at <= ?))"
|
|
315
|
+
") RETURNING *",
|
|
316
|
+
(owner, now + lease_seconds, owner, operation_id, owner, now),
|
|
317
|
+
)
|
|
318
|
+
if rows:
|
|
319
|
+
return self._from_row(rows[0])
|
|
320
|
+
current = self.get(operation_id)
|
|
321
|
+
if current.state is IngestionState.COMPLETE:
|
|
322
|
+
return current
|
|
323
|
+
raise OperationInProgress(
|
|
324
|
+
f"operation {operation_id} is leased by another worker"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def transition(
|
|
328
|
+
self,
|
|
329
|
+
operation_id: str,
|
|
330
|
+
*,
|
|
331
|
+
expected: IngestionState,
|
|
332
|
+
target: IngestionState,
|
|
333
|
+
queryable_fact_ids: tuple[str, ...] | None = None,
|
|
334
|
+
final_fact_ids: tuple[str, ...] | None = None,
|
|
335
|
+
derivation_version: str | None = None,
|
|
336
|
+
derivation_state: dict[str, bool] | None = None,
|
|
337
|
+
last_error: str | None = None,
|
|
338
|
+
) -> IngestionOperation:
|
|
339
|
+
if target not in self._ALLOWED[expected]:
|
|
340
|
+
raise InvalidStateTransition(f"{expected.value} -> {target.value}")
|
|
341
|
+
rows = self.db.execute(
|
|
342
|
+
"UPDATE ingestion_operations SET state=?, "
|
|
343
|
+
"queryable_fact_ids_json=COALESCE(?, queryable_fact_ids_json), "
|
|
344
|
+
"final_fact_ids_json=COALESCE(?, final_fact_ids_json), "
|
|
345
|
+
"derivation_version=COALESCE(?, derivation_version), "
|
|
346
|
+
"derivation_state_json=COALESCE(?, derivation_state_json), "
|
|
347
|
+
"attempt_count=attempt_count + ?, "
|
|
348
|
+
"last_error=?, "
|
|
349
|
+
"updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
|
|
350
|
+
"WHERE operation_id=? AND state=? RETURNING *",
|
|
351
|
+
(
|
|
352
|
+
target.value,
|
|
353
|
+
_canonical_json(list(queryable_fact_ids))
|
|
354
|
+
if queryable_fact_ids is not None
|
|
355
|
+
else None,
|
|
356
|
+
_canonical_json(list(final_fact_ids))
|
|
357
|
+
if final_fact_ids is not None
|
|
358
|
+
else None,
|
|
359
|
+
derivation_version,
|
|
360
|
+
_canonical_json(derivation_state)
|
|
361
|
+
if derivation_state is not None
|
|
362
|
+
else None,
|
|
363
|
+
1 if target is IngestionState.ENRICHING else 0,
|
|
364
|
+
last_error or "",
|
|
365
|
+
operation_id,
|
|
366
|
+
expected.value,
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
if not rows:
|
|
370
|
+
actual = self.get(operation_id).state
|
|
371
|
+
raise InvalidStateTransition(
|
|
372
|
+
f"expected {expected.value}, found {actual.value}"
|
|
373
|
+
)
|
|
374
|
+
return self._from_row(rows[0])
|
|
375
|
+
|
|
376
|
+
def checkpoint_enriching(
|
|
377
|
+
self,
|
|
378
|
+
operation_id: str,
|
|
379
|
+
*,
|
|
380
|
+
final_fact_ids: tuple[str, ...],
|
|
381
|
+
derivation_version: str,
|
|
382
|
+
derivation_state: dict[str, bool],
|
|
383
|
+
lease_owner: str,
|
|
384
|
+
lease_seconds: float,
|
|
385
|
+
) -> IngestionOperation:
|
|
386
|
+
"""Durably checkpoint relational derivation before external indexes."""
|
|
387
|
+
rows = self.db.execute(
|
|
388
|
+
"UPDATE ingestion_operations SET "
|
|
389
|
+
"final_fact_ids_json=?, derivation_version=?, "
|
|
390
|
+
"derivation_state_json=?, lease_expires_at=?, last_error='', "
|
|
391
|
+
"updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
|
|
392
|
+
"WHERE operation_id=? AND state='enriching' AND lease_owner=? "
|
|
393
|
+
"RETURNING *",
|
|
394
|
+
(
|
|
395
|
+
_canonical_json(list(final_fact_ids)),
|
|
396
|
+
derivation_version,
|
|
397
|
+
_canonical_json(derivation_state),
|
|
398
|
+
time.time() + max(1.0, float(lease_seconds)),
|
|
399
|
+
operation_id,
|
|
400
|
+
lease_owner,
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
if not rows:
|
|
404
|
+
raise InvalidStateTransition("enriching checkpoint lost ownership")
|
|
405
|
+
operation = self._from_row(rows[0])
|
|
406
|
+
from superlocalmemory.core.derivation_lineage import capture_operation_lineage
|
|
407
|
+
|
|
408
|
+
capture_operation_lineage(
|
|
409
|
+
self.db,
|
|
410
|
+
operation_id=operation.operation_id,
|
|
411
|
+
profile_id=operation.profile_id,
|
|
412
|
+
raw_content=operation.raw_content,
|
|
413
|
+
fact_ids=operation.final_fact_ids,
|
|
414
|
+
derivation_version=operation.derivation_version,
|
|
415
|
+
)
|
|
416
|
+
return operation
|
|
417
|
+
|
|
418
|
+
def finish_enriching(
|
|
419
|
+
self,
|
|
420
|
+
operation_id: str,
|
|
421
|
+
*,
|
|
422
|
+
owner: str,
|
|
423
|
+
target: IngestionState,
|
|
424
|
+
final_fact_ids: tuple[str, ...] | None = None,
|
|
425
|
+
derivation_version: str | None = None,
|
|
426
|
+
derivation_state: dict[str, bool] | None = None,
|
|
427
|
+
last_error: str = "",
|
|
428
|
+
) -> IngestionOperation:
|
|
429
|
+
"""Finish only work owned by the caller's durable lease."""
|
|
430
|
+
if target not in {IngestionState.COMPLETE, IngestionState.FAILED}:
|
|
431
|
+
raise InvalidStateTransition(f"enriching -> {target.value}")
|
|
432
|
+
current = self.get(operation_id)
|
|
433
|
+
retry_at = 0.0
|
|
434
|
+
if target is IngestionState.FAILED:
|
|
435
|
+
delay = min(2 ** min(max(current.attempt_count, 1), 10), 300)
|
|
436
|
+
retry_at = time.time() + delay
|
|
437
|
+
rows = self.db.execute(
|
|
438
|
+
"UPDATE ingestion_operations SET state=?, "
|
|
439
|
+
"final_fact_ids_json=COALESCE(?, final_fact_ids_json), "
|
|
440
|
+
"derivation_version=COALESCE(?, derivation_version), "
|
|
441
|
+
"derivation_state_json=COALESCE(?, derivation_state_json), "
|
|
442
|
+
"lease_owner='', lease_expires_at=0, next_retry_at=?, last_error=?, "
|
|
443
|
+
"updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
|
|
444
|
+
"WHERE operation_id=? AND state='enriching' AND lease_owner=? "
|
|
445
|
+
"RETURNING *",
|
|
446
|
+
(
|
|
447
|
+
target.value,
|
|
448
|
+
_canonical_json(list(final_fact_ids))
|
|
449
|
+
if final_fact_ids is not None
|
|
450
|
+
else None,
|
|
451
|
+
derivation_version,
|
|
452
|
+
_canonical_json(derivation_state)
|
|
453
|
+
if derivation_state is not None
|
|
454
|
+
else None,
|
|
455
|
+
retry_at,
|
|
456
|
+
last_error,
|
|
457
|
+
operation_id,
|
|
458
|
+
owner,
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
if not rows:
|
|
462
|
+
raise InvalidStateTransition("enriching lease ownership was lost")
|
|
463
|
+
return self._from_row(rows[0])
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@dataclass(frozen=True, slots=True)
|
|
467
|
+
class MaterializationResult:
|
|
468
|
+
"""Final fact IDs plus the declared derivation stages actually completed."""
|
|
469
|
+
|
|
470
|
+
fact_ids: tuple[str, ...]
|
|
471
|
+
derivation_state: dict[str, bool]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
QueryableWriter = Callable[[IngestionRequest, str], list[str]]
|
|
475
|
+
Materializer = Callable[
|
|
476
|
+
[IngestionOperation],
|
|
477
|
+
list[str] | tuple[str, ...] | MaterializationResult,
|
|
478
|
+
]
|
|
479
|
+
Projector = Callable[[IngestionOperation], dict[str, bool]]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class IngestionCommand:
|
|
483
|
+
"""Coordinates durable submission and idempotent enrichment."""
|
|
484
|
+
|
|
485
|
+
def __init__(
|
|
486
|
+
self,
|
|
487
|
+
repository: IngestionOperationRepository,
|
|
488
|
+
*,
|
|
489
|
+
write_queryable: QueryableWriter,
|
|
490
|
+
materialize: Materializer,
|
|
491
|
+
project: Projector | None = None,
|
|
492
|
+
derivation_version: str = "v3.7-ingestion-1",
|
|
493
|
+
lease_seconds: float = 900.0,
|
|
494
|
+
) -> None:
|
|
495
|
+
self.repository = repository
|
|
496
|
+
self._write_queryable = write_queryable
|
|
497
|
+
self._materializer = materialize
|
|
498
|
+
self._projector = project
|
|
499
|
+
self._derivation_version = derivation_version
|
|
500
|
+
self._lease_seconds = max(1.0, float(lease_seconds))
|
|
501
|
+
self._owner = f"ingestion-worker:{uuid.uuid4().hex}"
|
|
502
|
+
|
|
503
|
+
def submit(self, request: IngestionRequest) -> IngestionOperation:
|
|
504
|
+
operation, _created = self.submit_with_status(request)
|
|
505
|
+
return operation
|
|
506
|
+
|
|
507
|
+
def submit_with_status(
|
|
508
|
+
self, request: IngestionRequest,
|
|
509
|
+
) -> tuple[IngestionOperation, bool]:
|
|
510
|
+
"""Submit once and report whether this call created the operation."""
|
|
511
|
+
with self.repository.db.transaction():
|
|
512
|
+
operation, created = self.repository.create_with_status(request)
|
|
513
|
+
if operation.state is not IngestionState.RAW:
|
|
514
|
+
return operation, created
|
|
515
|
+
fact_ids = tuple(self._write_queryable(request, operation.operation_id))
|
|
516
|
+
if not fact_ids:
|
|
517
|
+
raise RuntimeError("ingestion produced no queryable facts")
|
|
518
|
+
receipt = self.repository.transition(
|
|
519
|
+
operation.operation_id,
|
|
520
|
+
expected=IngestionState.RAW,
|
|
521
|
+
target=IngestionState.QUERYABLE,
|
|
522
|
+
queryable_fact_ids=fact_ids,
|
|
523
|
+
)
|
|
524
|
+
return receipt, created
|
|
525
|
+
|
|
526
|
+
def materialize(self, operation_id: str) -> IngestionOperation:
|
|
527
|
+
# Coalesce in-process HTTP/background/worker races for the same
|
|
528
|
+
# operation. Database compare-and-swap remains the cross-process gate.
|
|
529
|
+
with _materialization_lock(operation_id):
|
|
530
|
+
return self._materialize_locked(operation_id)
|
|
531
|
+
|
|
532
|
+
def _materialize_locked(self, operation_id: str) -> IngestionOperation:
|
|
533
|
+
operation = self.repository.get(operation_id)
|
|
534
|
+
if operation.state is IngestionState.COMPLETE:
|
|
535
|
+
return operation
|
|
536
|
+
if operation.state not in {
|
|
537
|
+
IngestionState.QUERYABLE,
|
|
538
|
+
IngestionState.ENRICHING,
|
|
539
|
+
IngestionState.FAILED,
|
|
540
|
+
}:
|
|
541
|
+
raise InvalidStateTransition(
|
|
542
|
+
f"cannot materialize operation in {operation.state.value}"
|
|
543
|
+
)
|
|
544
|
+
enriching = self.repository.claim_enriching(
|
|
545
|
+
operation_id,
|
|
546
|
+
owner=self._owner,
|
|
547
|
+
lease_seconds=self._lease_seconds,
|
|
548
|
+
)
|
|
549
|
+
if enriching.state is IngestionState.COMPLETE:
|
|
550
|
+
return enriching
|
|
551
|
+
if enriching.final_fact_ids:
|
|
552
|
+
return self._project_and_complete(enriching)
|
|
553
|
+
try:
|
|
554
|
+
with self.repository.db.transaction():
|
|
555
|
+
materialized = self._materializer(enriching)
|
|
556
|
+
if isinstance(materialized, MaterializationResult):
|
|
557
|
+
fact_ids = tuple(materialized.fact_ids)
|
|
558
|
+
derivation_state = dict(materialized.derivation_state)
|
|
559
|
+
else:
|
|
560
|
+
fact_ids = tuple(materialized)
|
|
561
|
+
derivation_state = {"materializer": True}
|
|
562
|
+
if not fact_ids:
|
|
563
|
+
raise RuntimeError("materialization produced no final facts")
|
|
564
|
+
incomplete = sorted(
|
|
565
|
+
name for name, complete in derivation_state.items()
|
|
566
|
+
if not complete
|
|
567
|
+
)
|
|
568
|
+
if incomplete:
|
|
569
|
+
raise RuntimeError(
|
|
570
|
+
"incomplete derivation stages: " + ", ".join(incomplete)
|
|
571
|
+
)
|
|
572
|
+
checkpointed = self.repository.checkpoint_enriching(
|
|
573
|
+
operation_id,
|
|
574
|
+
final_fact_ids=fact_ids,
|
|
575
|
+
derivation_version=self._derivation_version,
|
|
576
|
+
derivation_state=derivation_state,
|
|
577
|
+
lease_owner=self._owner,
|
|
578
|
+
lease_seconds=self._lease_seconds,
|
|
579
|
+
)
|
|
580
|
+
except Exception as exc:
|
|
581
|
+
return self.repository.finish_enriching(
|
|
582
|
+
operation_id,
|
|
583
|
+
owner=self._owner,
|
|
584
|
+
target=IngestionState.FAILED,
|
|
585
|
+
last_error=str(exc),
|
|
586
|
+
)
|
|
587
|
+
return self._project_and_complete(checkpointed)
|
|
588
|
+
|
|
589
|
+
def _project_and_complete(
|
|
590
|
+
self, operation: IngestionOperation,
|
|
591
|
+
) -> IngestionOperation:
|
|
592
|
+
try:
|
|
593
|
+
# The relational checkpoint extends ownership before optional ANN
|
|
594
|
+
# and vector projectors, whose cold-start latency can be material.
|
|
595
|
+
# Re-claiming with the same owner renews the lease atomically.
|
|
596
|
+
operation = self.repository.claim_enriching(
|
|
597
|
+
operation.operation_id,
|
|
598
|
+
owner=self._owner,
|
|
599
|
+
lease_seconds=self._lease_seconds,
|
|
600
|
+
)
|
|
601
|
+
projection_state = (
|
|
602
|
+
dict(self._projector(operation))
|
|
603
|
+
if self._projector is not None
|
|
604
|
+
else {}
|
|
605
|
+
)
|
|
606
|
+
combined = {**operation.derivation_state, **projection_state}
|
|
607
|
+
incomplete = sorted(
|
|
608
|
+
name for name, complete in combined.items() if not complete
|
|
609
|
+
)
|
|
610
|
+
if incomplete:
|
|
611
|
+
raise RuntimeError(
|
|
612
|
+
"incomplete derivation stages: " + ", ".join(incomplete)
|
|
613
|
+
)
|
|
614
|
+
return self.repository.finish_enriching(
|
|
615
|
+
operation.operation_id,
|
|
616
|
+
owner=self._owner,
|
|
617
|
+
target=IngestionState.COMPLETE,
|
|
618
|
+
final_fact_ids=operation.final_fact_ids,
|
|
619
|
+
derivation_version=self._derivation_version,
|
|
620
|
+
derivation_state=combined,
|
|
621
|
+
)
|
|
622
|
+
except Exception as exc:
|
|
623
|
+
return self.repository.finish_enriching(
|
|
624
|
+
operation.operation_id,
|
|
625
|
+
owner=self._owner,
|
|
626
|
+
target=IngestionState.FAILED,
|
|
627
|
+
last_error=str(exc),
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def retry(self, operation_id: str) -> IngestionOperation:
|
|
631
|
+
operation = self.repository.get(operation_id)
|
|
632
|
+
if operation.state is not IngestionState.FAILED:
|
|
633
|
+
raise InvalidStateTransition(
|
|
634
|
+
f"cannot retry operation in {operation.state.value}"
|
|
635
|
+
)
|
|
636
|
+
return self.materialize(operation_id)
|