superlocalmemory 3.7.1 → 3.7.3
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 +19 -0
- package/README.md +16 -8
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +11 -3
- package/plugin/scripts/slm-launch.bat +8 -2
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/scripts/slm-launch +11 -3
- package/plugin-src/scripts/slm-launch.bat +8 -2
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/main.py +2 -2
- package/src/superlocalmemory/cli/scale_engine_cmd.py +13 -1
- package/src/superlocalmemory/core/backend_orchestrator.py +15 -0
- package/src/superlocalmemory/core/embedding_worker.py +7 -1
- package/src/superlocalmemory/core/embeddings.py +8 -0
- package/src/superlocalmemory/core/engine.py +1 -3
- package/src/superlocalmemory/core/engine_wiring.py +0 -5
- package/src/superlocalmemory/core/ram_lock.py +42 -4
- package/src/superlocalmemory/core/scale_engine.py +450 -35
- package/src/superlocalmemory/evolution/budget.py +43 -8
- package/src/superlocalmemory/hooks/claude_code_hooks.py +2 -1
- package/src/superlocalmemory/hooks/context_payload.py +2 -1
- package/src/superlocalmemory/mcp/http_transport.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +46 -29
- package/src/superlocalmemory/mesh/broker.py +111 -61
- package/src/superlocalmemory/optimize/proxy/server.py +2 -1
- package/src/superlocalmemory/retrieval/spreading_activation.py +53 -3
- package/src/superlocalmemory/server/routes/brain.py +2 -1
- package/src/superlocalmemory/server/unified_daemon.py +24 -7
|
@@ -41,17 +41,20 @@ class ScaleEngineManager:
|
|
|
41
41
|
|
|
42
42
|
MANIFEST_NAME = "scale-engine.json"
|
|
43
43
|
SCHEMA_VERSION = 1
|
|
44
|
+
LIFECYCLE_LOCK = "scale-engine.lifecycle.lock"
|
|
45
|
+
PROMOTION_JOURNAL = "scale-engine.promotion.json"
|
|
44
46
|
|
|
45
47
|
def __init__(
|
|
46
48
|
self,
|
|
47
49
|
config: Any,
|
|
48
50
|
*,
|
|
49
51
|
backend_factory: Callable[[Path, Path], tuple[Any, Any]] | None = None,
|
|
52
|
+
profile_id: str | None = None,
|
|
50
53
|
) -> None:
|
|
51
54
|
self.config = config
|
|
52
55
|
self.data_dir = Path(getattr(config, "data_dir", None) or config.base_dir)
|
|
53
56
|
self.db_path = Path(getattr(config, "db_path", None) or self.data_dir / "memory.db")
|
|
54
|
-
self.profile_id = getattr(config, "active_profile", "default")
|
|
57
|
+
self.profile_id = profile_id or getattr(config, "active_profile", "default")
|
|
55
58
|
self._backend_factory = backend_factory or self._real_backend_factory
|
|
56
59
|
|
|
57
60
|
@property
|
|
@@ -66,6 +69,14 @@ class ScaleEngineManager:
|
|
|
66
69
|
def active_paths(self) -> tuple[Path, Path]:
|
|
67
70
|
return self.data_dir / "cozo", self.data_dir / "lance"
|
|
68
71
|
|
|
72
|
+
@property
|
|
73
|
+
def lifecycle_lock_path(self) -> Path:
|
|
74
|
+
return self.data_dir / self.LIFECYCLE_LOCK
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def promotion_journal_path(self) -> Path:
|
|
78
|
+
return self.data_dir / self.PROMOTION_JOURNAL
|
|
79
|
+
|
|
69
80
|
def status(self) -> dict[str, Any]:
|
|
70
81
|
manifests: list[dict[str, Any]] = []
|
|
71
82
|
if self.staging_root.exists():
|
|
@@ -74,21 +85,93 @@ class ScaleEngineManager:
|
|
|
74
85
|
manifests.append(json.loads(path.read_text()))
|
|
75
86
|
except (OSError, json.JSONDecodeError):
|
|
76
87
|
manifests.append({"stage_id": path.parent.name, "state": "corrupt"})
|
|
88
|
+
backups = (
|
|
89
|
+
sorted(p.name for p in self.backup_root.glob("*") if p.is_dir())
|
|
90
|
+
if self.backup_root.exists()
|
|
91
|
+
else []
|
|
92
|
+
)
|
|
93
|
+
paths_present = {
|
|
94
|
+
"cozo": self.active_paths[0].exists(),
|
|
95
|
+
"lance": self.active_paths[1].exists(),
|
|
96
|
+
}
|
|
97
|
+
state = getattr(self.config, "scale_engine_state", "local_core")
|
|
98
|
+
legacy_projection_candidate = (
|
|
99
|
+
state == "local_core"
|
|
100
|
+
and all(paths_present.values())
|
|
101
|
+
and not manifests
|
|
102
|
+
and not backups
|
|
103
|
+
and self._has_legacy_projection_layout()
|
|
104
|
+
)
|
|
105
|
+
runtime = self._runtime_backend_status()
|
|
77
106
|
return {
|
|
78
|
-
"state":
|
|
79
|
-
|
|
107
|
+
"state": state,
|
|
108
|
+
# This command reads persisted state, not the live daemon. Never
|
|
109
|
+
# turn a last-known backend row into a present-tense routing claim.
|
|
110
|
+
"active": {"cozo": False, "lance": False},
|
|
111
|
+
"last_daemon_observation": runtime,
|
|
112
|
+
"paths_present": paths_present,
|
|
113
|
+
"retrieval_routing": (
|
|
114
|
+
"daemon_runtime_check_required" if state == "promoted" else "canonical_sqlite"
|
|
115
|
+
),
|
|
116
|
+
"legacy_projection_candidate": legacy_projection_candidate,
|
|
117
|
+
"legacy_candidate_requires_confirmation": legacy_projection_candidate,
|
|
118
|
+
"migration_repair_required": (
|
|
119
|
+
self.promotion_journal_path.exists()
|
|
120
|
+
or (state == "local_core" and bool(manifests))
|
|
121
|
+
),
|
|
80
122
|
"stages": manifests,
|
|
81
|
-
"backups":
|
|
123
|
+
"backups": backups,
|
|
82
124
|
}
|
|
83
125
|
|
|
126
|
+
def adopt_legacy_projection(self) -> dict[str, Any] | None:
|
|
127
|
+
"""Safely adopt a v3.5-era projection into the staged lifecycle.
|
|
128
|
+
|
|
129
|
+
A legacy projection proves only that an older runtime created files.
|
|
130
|
+
It cannot establish parity with today's canonical SQLite database.
|
|
131
|
+
Rebuild a fresh stage, verify it while the canonical database is
|
|
132
|
+
stable, and promote it atomically; the legacy directories become the
|
|
133
|
+
explicit rollback copy.
|
|
134
|
+
"""
|
|
135
|
+
if not self.status()["legacy_projection_candidate"]:
|
|
136
|
+
return None
|
|
137
|
+
lock_path = self._acquire_lifecycle_lock()
|
|
138
|
+
try:
|
|
139
|
+
# Re-check inside the lock. A concurrent command may have
|
|
140
|
+
# completed promotion while this caller waited to acquire it.
|
|
141
|
+
if not self.status()["legacy_projection_candidate"]:
|
|
142
|
+
return None
|
|
143
|
+
prepared = self._prepare()
|
|
144
|
+
self._verify(prepared["stage_id"])
|
|
145
|
+
return self._promote(prepared["stage_id"])
|
|
146
|
+
except Exception:
|
|
147
|
+
# A failed adoption must leave the canonical path selected. The
|
|
148
|
+
# stage is retained for operator inspection instead of deleting
|
|
149
|
+
# evidence about why an upgrade could not be completed.
|
|
150
|
+
self.config.scale_engine_state = "local_core"
|
|
151
|
+
self.config.graph_backend = "auto"
|
|
152
|
+
self.config.vector_backend = "auto"
|
|
153
|
+
self._save_config()
|
|
154
|
+
raise
|
|
155
|
+
finally:
|
|
156
|
+
self._release_lifecycle_lock(lock_path)
|
|
157
|
+
|
|
84
158
|
def prepare(self) -> dict[str, Any]:
|
|
85
159
|
"""Build a new projection in a private staging directory."""
|
|
160
|
+
lock_path = self._acquire_lifecycle_lock()
|
|
161
|
+
try:
|
|
162
|
+
self._recover_interrupted_promotion()
|
|
163
|
+
return self._prepare()
|
|
164
|
+
finally:
|
|
165
|
+
self._release_lifecycle_lock(lock_path)
|
|
166
|
+
|
|
167
|
+
def _prepare(self) -> dict[str, Any]:
|
|
168
|
+
"""Build a new projection while the caller owns the lifecycle lock."""
|
|
86
169
|
self._require_default_profile()
|
|
87
170
|
self._require_canonical_db()
|
|
88
171
|
stage_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
|
|
89
172
|
stage_dir = self.staging_root / stage_id
|
|
90
173
|
cozo_dir, lance_dir = stage_dir / "cozo", stage_dir / "lance"
|
|
91
|
-
|
|
174
|
+
self._mkdir_durable(stage_dir, exist_ok=False)
|
|
92
175
|
cozo = lance = None
|
|
93
176
|
try:
|
|
94
177
|
cozo, lance = self._backend_factory(cozo_dir, lance_dir)
|
|
@@ -96,6 +179,7 @@ class ScaleEngineManager:
|
|
|
96
179
|
cozo.bulk_import_from_sqlite(conn, self.profile_id)
|
|
97
180
|
lance.bulk_import_from_sqlite(conn, self.profile_id)
|
|
98
181
|
canonical = self._canonical_counts(conn)
|
|
182
|
+
source_fingerprint = self._projection_fingerprint(conn, canonical)
|
|
99
183
|
observed = self._observed_counts(cozo, lance)
|
|
100
184
|
manifest = {
|
|
101
185
|
"schema_version": self.SCHEMA_VERSION,
|
|
@@ -105,7 +189,7 @@ class ScaleEngineManager:
|
|
|
105
189
|
"profile_id": self.profile_id,
|
|
106
190
|
"canonical": canonical,
|
|
107
191
|
"observed": observed,
|
|
108
|
-
"source_fingerprint":
|
|
192
|
+
"source_fingerprint": source_fingerprint,
|
|
109
193
|
}
|
|
110
194
|
self._write_manifest(stage_dir, manifest)
|
|
111
195
|
self.config.scale_engine_state = "prepared"
|
|
@@ -120,6 +204,15 @@ class ScaleEngineManager:
|
|
|
120
204
|
|
|
121
205
|
def verify(self, stage_id: str) -> dict[str, Any]:
|
|
122
206
|
"""Prove a staged projection matches the current canonical SQLite data."""
|
|
207
|
+
lock_path = self._acquire_lifecycle_lock()
|
|
208
|
+
try:
|
|
209
|
+
self._recover_interrupted_promotion()
|
|
210
|
+
return self._verify(stage_id)
|
|
211
|
+
finally:
|
|
212
|
+
self._release_lifecycle_lock(lock_path)
|
|
213
|
+
|
|
214
|
+
def _verify(self, stage_id: str) -> dict[str, Any]:
|
|
215
|
+
"""Verify while the caller owns the lifecycle lock."""
|
|
123
216
|
stage_dir, manifest = self._load_stage(stage_id)
|
|
124
217
|
self._validate_manifest(manifest, state="prepared")
|
|
125
218
|
self._require_default_profile()
|
|
@@ -128,8 +221,9 @@ class ScaleEngineManager:
|
|
|
128
221
|
cozo, lance = self._backend_factory(stage_dir / "cozo", stage_dir / "lance")
|
|
129
222
|
with self._readonly_connection() as conn:
|
|
130
223
|
canonical = self._canonical_counts(conn)
|
|
224
|
+
source_fingerprint = self._projection_fingerprint(conn, canonical)
|
|
131
225
|
observed = self._observed_counts(cozo, lance)
|
|
132
|
-
if manifest["source_fingerprint"] !=
|
|
226
|
+
if manifest["source_fingerprint"] != source_fingerprint:
|
|
133
227
|
raise ScaleEngineError("canonical SQLite changed after preparation; prepare a new stage")
|
|
134
228
|
if canonical != manifest["canonical"] or observed != canonical:
|
|
135
229
|
raise ScaleEngineError(
|
|
@@ -146,6 +240,15 @@ class ScaleEngineManager:
|
|
|
146
240
|
|
|
147
241
|
def promote(self, stage_id: str) -> dict[str, Any]:
|
|
148
242
|
"""Move a verified stage into active paths, preserving a rollback copy."""
|
|
243
|
+
lock_path = self._acquire_lifecycle_lock()
|
|
244
|
+
try:
|
|
245
|
+
self._recover_interrupted_promotion()
|
|
246
|
+
return self._promote(stage_id)
|
|
247
|
+
finally:
|
|
248
|
+
self._release_lifecycle_lock(lock_path)
|
|
249
|
+
|
|
250
|
+
def _promote(self, stage_id: str) -> dict[str, Any]:
|
|
251
|
+
"""Promote while the caller owns the lifecycle lock."""
|
|
149
252
|
stage_dir, manifest = self._load_stage(stage_id)
|
|
150
253
|
self._validate_manifest(manifest, state="verified")
|
|
151
254
|
staged = (stage_dir / "cozo", stage_dir / "lance")
|
|
@@ -153,39 +256,78 @@ class ScaleEngineManager:
|
|
|
153
256
|
raise ScaleEngineError("verified stage is incomplete; prepare a new stage")
|
|
154
257
|
backup_dir = self.backup_root / f"{stage_id}-{uuid.uuid4().hex[:6]}"
|
|
155
258
|
active = self.active_paths
|
|
156
|
-
self.
|
|
157
|
-
# Keep an explicit empty rollback point as well: a first promotion has
|
|
158
|
-
# no former projection directories, but rollback must still be able to
|
|
159
|
-
# return the installation to Local Core without deleting anything.
|
|
160
|
-
backup_dir.mkdir(parents=True, exist_ok=False)
|
|
161
|
-
moved_active: list[tuple[Path, Path]] = []
|
|
162
|
-
moved_stage: list[tuple[Path, Path]] = []
|
|
259
|
+
gate = sqlite3.connect(self.db_path, timeout=30)
|
|
163
260
|
try:
|
|
261
|
+
# The stage was built from a point-in-time SQLite snapshot. Hold a
|
|
262
|
+
# short writer fence for the final fingerprint check and directory
|
|
263
|
+
# swap so no successful promotion can trail a canonical write.
|
|
264
|
+
gate.execute("BEGIN IMMEDIATE")
|
|
265
|
+
canonical = self._canonical_counts(gate)
|
|
266
|
+
if manifest["source_fingerprint"] != self._projection_fingerprint(gate, canonical):
|
|
267
|
+
raise ScaleEngineError("canonical SQLite changed after verification; prepare a new stage")
|
|
268
|
+
self._mkdir_durable(self.backup_root)
|
|
269
|
+
journal = {
|
|
270
|
+
"schema_version": self.SCHEMA_VERSION,
|
|
271
|
+
"operation": "promotion",
|
|
272
|
+
"state": "intent",
|
|
273
|
+
"stage_id": stage_id,
|
|
274
|
+
"backup_id": backup_dir.name,
|
|
275
|
+
"moves": [],
|
|
276
|
+
}
|
|
277
|
+
self._write_promotion_journal(journal)
|
|
278
|
+
self._mkdir_durable(backup_dir, exist_ok=False)
|
|
164
279
|
for name, source, destination in zip(("cozo", "lance"), active, staged):
|
|
165
280
|
if source.exists():
|
|
166
281
|
target = backup_dir / name
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
282
|
+
move = {"name": name, "kind": "active_to_backup", "state": "intent"}
|
|
283
|
+
journal["moves"].append(move)
|
|
284
|
+
self._write_promotion_journal(journal)
|
|
285
|
+
self._replace_durable(source, target)
|
|
286
|
+
move["state"] = "complete"
|
|
287
|
+
self._write_promotion_journal(journal)
|
|
288
|
+
move = {"name": name, "kind": "stage_to_active", "state": "intent"}
|
|
289
|
+
journal["moves"].append(move)
|
|
290
|
+
self._write_promotion_journal(journal)
|
|
291
|
+
self._replace_durable(destination, source)
|
|
292
|
+
move["state"] = "complete"
|
|
293
|
+
self._write_promotion_journal(journal)
|
|
171
294
|
manifest.update({"state": "promoted", "promoted_at": _utc_now(), "backup_id": backup_dir.name})
|
|
172
295
|
self._write_manifest(stage_dir, manifest)
|
|
173
296
|
self.config.scale_engine_state = "promoted"
|
|
174
297
|
self.config.graph_backend = "cozo"
|
|
175
298
|
self.config.vector_backend = "lancedb"
|
|
176
299
|
self._save_config()
|
|
300
|
+
journal["state"] = "committed"
|
|
301
|
+
self._write_promotion_journal(journal)
|
|
302
|
+
self.promotion_journal_path.unlink(missing_ok=True)
|
|
303
|
+
gate.rollback()
|
|
177
304
|
return manifest
|
|
178
305
|
except Exception as exc:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
306
|
+
try:
|
|
307
|
+
gate.rollback()
|
|
308
|
+
except sqlite3.Error:
|
|
309
|
+
pass
|
|
310
|
+
try:
|
|
311
|
+
self._recover_interrupted_promotion()
|
|
312
|
+
except ScaleEngineError as recovery_error:
|
|
313
|
+
raise ScaleEngineError(
|
|
314
|
+
f"promotion interrupted; automatic recovery needs repair: {recovery_error}"
|
|
315
|
+
) from exc
|
|
185
316
|
raise ScaleEngineError(f"promotion rolled back: {exc}") from exc
|
|
317
|
+
finally:
|
|
318
|
+
gate.close()
|
|
186
319
|
|
|
187
320
|
def rollback(self, backup_id: str) -> dict[str, Any]:
|
|
188
321
|
"""Restore an explicitly named pre-promotion backup."""
|
|
322
|
+
lock_path = self._acquire_lifecycle_lock()
|
|
323
|
+
try:
|
|
324
|
+
self._recover_interrupted_promotion()
|
|
325
|
+
return self._rollback(backup_id)
|
|
326
|
+
finally:
|
|
327
|
+
self._release_lifecycle_lock(lock_path)
|
|
328
|
+
|
|
329
|
+
def _rollback(self, backup_id: str) -> dict[str, Any]:
|
|
330
|
+
"""Roll back while the caller owns the lifecycle lock."""
|
|
189
331
|
backup_dir = self.backup_root / backup_id
|
|
190
332
|
if not backup_dir.is_dir():
|
|
191
333
|
raise ScaleEngineError(f"backup does not exist: {backup_id}")
|
|
@@ -193,19 +335,47 @@ class ScaleEngineManager:
|
|
|
193
335
|
active = self.active_paths
|
|
194
336
|
displaced = self.backup_root / f"rollback-displaced-{uuid.uuid4().hex[:8]}"
|
|
195
337
|
try:
|
|
338
|
+
journal = {
|
|
339
|
+
"schema_version": self.SCHEMA_VERSION,
|
|
340
|
+
"operation": "rollback",
|
|
341
|
+
"state": "intent",
|
|
342
|
+
"backup_id": backup_id,
|
|
343
|
+
"displaced_id": displaced.name,
|
|
344
|
+
"moves": [],
|
|
345
|
+
}
|
|
346
|
+
self._write_promotion_journal(journal)
|
|
196
347
|
for name, source, target in zip(("cozo", "lance"), active, backup_paths):
|
|
197
348
|
if source.exists():
|
|
198
|
-
|
|
199
|
-
|
|
349
|
+
self._mkdir_durable(displaced)
|
|
350
|
+
move = {"name": name, "kind": "active_to_displaced", "state": "intent"}
|
|
351
|
+
journal["moves"].append(move)
|
|
352
|
+
self._write_promotion_journal(journal)
|
|
353
|
+
self._replace_durable(source, displaced / name)
|
|
354
|
+
move["state"] = "complete"
|
|
355
|
+
self._write_promotion_journal(journal)
|
|
200
356
|
if target.exists():
|
|
201
|
-
|
|
357
|
+
move = {"name": name, "kind": "backup_to_active", "state": "intent"}
|
|
358
|
+
journal["moves"].append(move)
|
|
359
|
+
self._write_promotion_journal(journal)
|
|
360
|
+
self._replace_durable(target, source)
|
|
361
|
+
move["state"] = "complete"
|
|
362
|
+
self._write_promotion_journal(journal)
|
|
202
363
|
self.config.scale_engine_state = "local_core"
|
|
203
364
|
self.config.graph_backend = "auto"
|
|
204
365
|
self.config.vector_backend = "auto"
|
|
205
366
|
self._save_config()
|
|
367
|
+
journal["state"] = "committed"
|
|
368
|
+
self._write_promotion_journal(journal)
|
|
369
|
+
self.promotion_journal_path.unlink(missing_ok=True)
|
|
206
370
|
return {"state": "local_core", "restored_backup": backup_id, "displaced": displaced.name}
|
|
207
371
|
except Exception as exc:
|
|
208
|
-
|
|
372
|
+
try:
|
|
373
|
+
self._recover_interrupted_promotion()
|
|
374
|
+
except ScaleEngineError as recovery_error:
|
|
375
|
+
raise ScaleEngineError(
|
|
376
|
+
f"rollback interrupted; automatic recovery needs repair: {recovery_error}"
|
|
377
|
+
) from exc
|
|
378
|
+
raise ScaleEngineError(f"rollback recovered: {exc}") from exc
|
|
209
379
|
|
|
210
380
|
def _real_backend_factory(self, cozo_dir: Path, lance_dir: Path) -> tuple[Any, Any]:
|
|
211
381
|
from superlocalmemory.graph.cozo_backend import CozoDBGraphBackend
|
|
@@ -243,17 +413,67 @@ class ScaleEngineManager:
|
|
|
243
413
|
raise ScaleEngineError(f"projection health failed: cozo={graph}, lancedb={vector}")
|
|
244
414
|
return {"entities": int(graph["entities"]), "edges": int(graph["edges"]), "vectors": int(vector["vectors"])}
|
|
245
415
|
|
|
246
|
-
def
|
|
416
|
+
def _projection_fingerprint(
|
|
417
|
+
self, conn: sqlite3.Connection, counts: dict[str, int]
|
|
418
|
+
) -> str:
|
|
419
|
+
"""Hash the exact projection source rows inside one SQLite snapshot."""
|
|
247
420
|
digest = hashlib.sha256()
|
|
248
421
|
digest.update(json.dumps(counts, sort_keys=True).encode())
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
422
|
+
tables = (
|
|
423
|
+
("canonical_entities", "entity_id, canonical_name, entity_type, first_seen, last_seen, fact_count, profile_id", "entity_id"),
|
|
424
|
+
("atomic_facts", "fact_id, canonical_entities_json, lifecycle, profile_id", "fact_id"),
|
|
425
|
+
("graph_edges", "source_id, target_id, edge_type, weight, profile_id", "source_id, target_id, edge_type"),
|
|
426
|
+
)
|
|
427
|
+
for table, columns, ordering in tables:
|
|
428
|
+
try:
|
|
429
|
+
rows = conn.execute(
|
|
430
|
+
f"SELECT {columns} FROM {table} WHERE profile_id=? ORDER BY {ordering}",
|
|
431
|
+
(self.profile_id,),
|
|
432
|
+
)
|
|
433
|
+
for row in rows:
|
|
434
|
+
self._digest_row(digest, table, row)
|
|
435
|
+
except sqlite3.OperationalError as exc:
|
|
436
|
+
raise ScaleEngineError(f"canonical SQLite missing required {table} table") from exc
|
|
437
|
+
try:
|
|
438
|
+
rows = conn.execute(
|
|
439
|
+
"SELECT fer.rowid, fer.fact_id, vec.vector FROM fact_embeddings_rowids fer "
|
|
440
|
+
"JOIN atomic_facts af ON af.fact_id = fer.fact_id "
|
|
441
|
+
"LEFT JOIN fact_embeddings_vector_chunks00 vec ON vec.rowid = fer.rowid "
|
|
442
|
+
"WHERE af.profile_id=? ORDER BY fer.rowid",
|
|
443
|
+
(self.profile_id,),
|
|
444
|
+
)
|
|
445
|
+
for row in rows:
|
|
446
|
+
self._digest_row(digest, "fact_embeddings_rowids", row)
|
|
447
|
+
except sqlite3.OperationalError:
|
|
448
|
+
pass
|
|
255
449
|
return digest.hexdigest()
|
|
256
450
|
|
|
451
|
+
@staticmethod
|
|
452
|
+
def _digest_row(digest: Any, table: str, row: Any) -> None:
|
|
453
|
+
digest.update(table.encode())
|
|
454
|
+
digest.update(json.dumps(list(row), default=str, separators=(",", ":")).encode())
|
|
455
|
+
|
|
456
|
+
def _has_legacy_projection_layout(self) -> bool:
|
|
457
|
+
cozo, lance = self.active_paths
|
|
458
|
+
return (cozo / "graph").is_dir() and (lance / "embeddings.lance").exists()
|
|
459
|
+
|
|
460
|
+
def _runtime_backend_status(self) -> dict[str, str]:
|
|
461
|
+
status = {"cozo": "unknown", "lance": "unknown"}
|
|
462
|
+
try:
|
|
463
|
+
with self._readonly_connection() as conn:
|
|
464
|
+
rows = conn.execute(
|
|
465
|
+
"SELECT backend_name, status FROM backend_status "
|
|
466
|
+
"WHERE backend_name IN ('cozo', 'lancedb')"
|
|
467
|
+
)
|
|
468
|
+
for name, value in rows:
|
|
469
|
+
if name == "cozo":
|
|
470
|
+
status["cozo"] = str(value)
|
|
471
|
+
elif name == "lancedb":
|
|
472
|
+
status["lance"] = str(value)
|
|
473
|
+
except sqlite3.OperationalError:
|
|
474
|
+
pass
|
|
475
|
+
return status
|
|
476
|
+
|
|
257
477
|
def _load_stage(self, stage_id: str) -> tuple[Path, dict[str, Any]]:
|
|
258
478
|
stage_dir = self.staging_root / stage_id
|
|
259
479
|
try:
|
|
@@ -280,6 +500,201 @@ class ScaleEngineManager:
|
|
|
280
500
|
if not self.db_path.exists():
|
|
281
501
|
raise ScaleEngineError(f"canonical SQLite database not found: {self.db_path}")
|
|
282
502
|
|
|
503
|
+
def _acquire_lifecycle_lock(self) -> Path:
|
|
504
|
+
"""Serialize every mutating lifecycle command across processes."""
|
|
505
|
+
lock_path = self.lifecycle_lock_path
|
|
506
|
+
descriptor = None
|
|
507
|
+
for attempt in range(2):
|
|
508
|
+
try:
|
|
509
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
510
|
+
break
|
|
511
|
+
except FileExistsError as exc:
|
|
512
|
+
if attempt == 0 and self._clear_dead_legacy_adoption_lock(lock_path):
|
|
513
|
+
continue
|
|
514
|
+
raise ScaleEngineError(
|
|
515
|
+
"Scale Engine lifecycle operation already in progress; retry after it completes"
|
|
516
|
+
) from exc
|
|
517
|
+
if descriptor is None:
|
|
518
|
+
raise ScaleEngineError("could not acquire Scale Engine lifecycle lock")
|
|
519
|
+
try:
|
|
520
|
+
with os.fdopen(descriptor, "w") as lock_file:
|
|
521
|
+
json.dump({"pid": os.getpid(), "started_at": _utc_now()}, lock_file)
|
|
522
|
+
except Exception:
|
|
523
|
+
lock_path.unlink(missing_ok=True)
|
|
524
|
+
raise
|
|
525
|
+
return lock_path
|
|
526
|
+
|
|
527
|
+
@staticmethod
|
|
528
|
+
def _release_lifecycle_lock(lock_path: Path) -> None:
|
|
529
|
+
lock_path.unlink(missing_ok=True)
|
|
530
|
+
|
|
531
|
+
@staticmethod
|
|
532
|
+
def _clear_dead_legacy_adoption_lock(lock_path: Path) -> bool:
|
|
533
|
+
"""Recover only a lock whose recorded process no longer exists."""
|
|
534
|
+
try:
|
|
535
|
+
owner = json.loads(lock_path.read_text())
|
|
536
|
+
pid = owner.get("pid")
|
|
537
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
538
|
+
return False
|
|
539
|
+
os.kill(pid, 0)
|
|
540
|
+
except ProcessLookupError:
|
|
541
|
+
lock_path.unlink(missing_ok=True)
|
|
542
|
+
return True
|
|
543
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
544
|
+
return False
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
def recover_interrupted_promotion(self) -> str | None:
|
|
548
|
+
"""Recover a durable promotion journal before opening projection paths."""
|
|
549
|
+
lock_path = self._acquire_lifecycle_lock()
|
|
550
|
+
try:
|
|
551
|
+
return self._recover_interrupted_promotion()
|
|
552
|
+
finally:
|
|
553
|
+
self._release_lifecycle_lock(lock_path)
|
|
554
|
+
|
|
555
|
+
def _recover_interrupted_promotion(self) -> str | None:
|
|
556
|
+
"""Finalize or reverse an interrupted directory swap under lifecycle lock."""
|
|
557
|
+
if not self.promotion_journal_path.exists():
|
|
558
|
+
return None
|
|
559
|
+
try:
|
|
560
|
+
journal = json.loads(self.promotion_journal_path.read_text())
|
|
561
|
+
backup_id = str(journal["backup_id"])
|
|
562
|
+
state = str(journal["state"])
|
|
563
|
+
except (OSError, KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
564
|
+
raise ScaleEngineError("invalid promotion journal; manual repair required") from exc
|
|
565
|
+
operation = str(journal.get("operation", "promotion"))
|
|
566
|
+
if state == "committed":
|
|
567
|
+
if operation == "promotion":
|
|
568
|
+
self.config.scale_engine_state = "promoted"
|
|
569
|
+
self.config.graph_backend = "cozo"
|
|
570
|
+
self.config.vector_backend = "lancedb"
|
|
571
|
+
elif operation == "rollback":
|
|
572
|
+
self.config.scale_engine_state = "local_core"
|
|
573
|
+
self.config.graph_backend = "auto"
|
|
574
|
+
self.config.vector_backend = "auto"
|
|
575
|
+
else:
|
|
576
|
+
raise ScaleEngineError(f"unknown journal operation: {operation!r}")
|
|
577
|
+
self._save_config()
|
|
578
|
+
self.promotion_journal_path.unlink(missing_ok=True)
|
|
579
|
+
return f"finalized_committed_{operation}"
|
|
580
|
+
if state != "intent":
|
|
581
|
+
raise ScaleEngineError(f"unknown promotion journal state: {state!r}")
|
|
582
|
+
backup_dir = self.backup_root / backup_id
|
|
583
|
+
active = dict(zip(("cozo", "lance"), self.active_paths))
|
|
584
|
+
stage_dir = self.staging_root / str(journal.get("stage_id", ""))
|
|
585
|
+
displaced_dir = self.backup_root / str(journal.get("displaced_id", ""))
|
|
586
|
+
moves = journal.get("moves")
|
|
587
|
+
if not isinstance(moves, list):
|
|
588
|
+
# Compatibility for journals written by the initial v3.7.3
|
|
589
|
+
# candidate before per-rename intents existed.
|
|
590
|
+
moves = [
|
|
591
|
+
{"name": name, "kind": "active_to_backup", "state": "complete"}
|
|
592
|
+
for name in journal.get("moved_active", [])
|
|
593
|
+
] + [
|
|
594
|
+
{"name": name, "kind": "stage_to_active", "state": "complete"}
|
|
595
|
+
for name in journal.get("moved_stage", [])
|
|
596
|
+
]
|
|
597
|
+
for move in reversed(moves):
|
|
598
|
+
self._reverse_journal_move(move, active, stage_dir, backup_dir, displaced_dir)
|
|
599
|
+
if operation == "promotion":
|
|
600
|
+
if backup_dir.exists() and not any(backup_dir.iterdir()):
|
|
601
|
+
backup_dir.rmdir()
|
|
602
|
+
self.config.scale_engine_state = "local_core"
|
|
603
|
+
self.config.graph_backend = "auto"
|
|
604
|
+
self.config.vector_backend = "auto"
|
|
605
|
+
elif operation == "rollback":
|
|
606
|
+
if displaced_dir.exists() and not any(displaced_dir.iterdir()):
|
|
607
|
+
displaced_dir.rmdir()
|
|
608
|
+
self.config.scale_engine_state = "promoted"
|
|
609
|
+
self.config.graph_backend = "cozo"
|
|
610
|
+
self.config.vector_backend = "lancedb"
|
|
611
|
+
else:
|
|
612
|
+
raise ScaleEngineError(f"unknown journal operation: {operation!r}")
|
|
613
|
+
self._save_config()
|
|
614
|
+
self.promotion_journal_path.unlink(missing_ok=True)
|
|
615
|
+
return f"reversed_interrupted_{operation}"
|
|
616
|
+
|
|
617
|
+
@staticmethod
|
|
618
|
+
def _reverse_journal_move(
|
|
619
|
+
move: Any,
|
|
620
|
+
active: dict[str, Path],
|
|
621
|
+
stage_dir: Path,
|
|
622
|
+
backup_dir: Path,
|
|
623
|
+
displaced_dir: Path,
|
|
624
|
+
) -> None:
|
|
625
|
+
"""Reverse one planned rename based on actual paths, not journal timing."""
|
|
626
|
+
if not isinstance(move, dict):
|
|
627
|
+
raise ScaleEngineError("invalid promotion journal move")
|
|
628
|
+
name = move.get("name")
|
|
629
|
+
kind = move.get("kind")
|
|
630
|
+
active_path = active.get(name)
|
|
631
|
+
if active_path is None:
|
|
632
|
+
raise ScaleEngineError(f"invalid promotion journal backend: {name!r}")
|
|
633
|
+
if kind == "stage_to_active":
|
|
634
|
+
source, target = active_path, stage_dir / name
|
|
635
|
+
elif kind == "active_to_backup":
|
|
636
|
+
source, target = backup_dir / name, active_path
|
|
637
|
+
elif kind == "active_to_displaced":
|
|
638
|
+
source, target = displaced_dir / name, active_path
|
|
639
|
+
elif kind == "backup_to_active":
|
|
640
|
+
source, target = active_path, backup_dir / name
|
|
641
|
+
else:
|
|
642
|
+
raise ScaleEngineError(f"invalid promotion journal move kind: {kind!r}")
|
|
643
|
+
if source.exists() and not target.exists():
|
|
644
|
+
ScaleEngineManager._replace_durable(source, target)
|
|
645
|
+
elif target.exists() and not source.exists():
|
|
646
|
+
return
|
|
647
|
+
else:
|
|
648
|
+
raise ScaleEngineError(f"cannot safely reconcile {kind} for {name}")
|
|
649
|
+
|
|
650
|
+
def _write_promotion_journal(self, journal: dict[str, Any]) -> None:
|
|
651
|
+
self._write_json_durable(self.promotion_journal_path, journal)
|
|
652
|
+
|
|
653
|
+
@staticmethod
|
|
654
|
+
def _write_json_durable(target: Path, payload: dict[str, Any]) -> None:
|
|
655
|
+
temporary = target.with_suffix(target.suffix + ".tmp")
|
|
656
|
+
with temporary.open("w") as handle:
|
|
657
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
658
|
+
handle.write("\n")
|
|
659
|
+
handle.flush()
|
|
660
|
+
os.fsync(handle.fileno())
|
|
661
|
+
os.replace(temporary, target)
|
|
662
|
+
ScaleEngineManager._fsync_directory(target.parent)
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
def _replace_durable(source: Path, target: Path) -> None:
|
|
666
|
+
"""Rename a projection path and persist both directory entries."""
|
|
667
|
+
os.replace(source, target)
|
|
668
|
+
ScaleEngineManager._fsync_directory(source.parent)
|
|
669
|
+
if target.parent != source.parent:
|
|
670
|
+
ScaleEngineManager._fsync_directory(target.parent)
|
|
671
|
+
|
|
672
|
+
@staticmethod
|
|
673
|
+
def _mkdir_durable(path: Path, *, exist_ok: bool = True) -> None:
|
|
674
|
+
"""Create a directory and persist every new parent entry before rename."""
|
|
675
|
+
missing: list[Path] = []
|
|
676
|
+
ancestor = path
|
|
677
|
+
while not ancestor.exists():
|
|
678
|
+
missing.append(ancestor)
|
|
679
|
+
ancestor = ancestor.parent
|
|
680
|
+
path.mkdir(parents=True, exist_ok=exist_ok)
|
|
681
|
+
for created in reversed(missing):
|
|
682
|
+
ScaleEngineManager._fsync_directory(created.parent)
|
|
683
|
+
|
|
684
|
+
@staticmethod
|
|
685
|
+
def _fsync_directory(directory_path: Path) -> None:
|
|
686
|
+
"""Best-effort directory-entry durability across local filesystems."""
|
|
687
|
+
try:
|
|
688
|
+
directory = os.open(directory_path, os.O_RDONLY)
|
|
689
|
+
try:
|
|
690
|
+
os.fsync(directory)
|
|
691
|
+
finally:
|
|
692
|
+
os.close(directory)
|
|
693
|
+
except OSError:
|
|
694
|
+
# The file itself is already durable where directory fsync is not
|
|
695
|
+
# supported by the local filesystem (notably some Windows setups).
|
|
696
|
+
pass
|
|
697
|
+
|
|
283
698
|
def _save_config(self) -> None:
|
|
284
699
|
save = getattr(self.config, "save", None)
|
|
285
700
|
if callable(save):
|