superlocalmemory 3.8.8 → 3.8.10
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 +44 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/daemon.py +89 -16
- package/src/superlocalmemory/core/embeddings.py +90 -6
- package/src/superlocalmemory/core/engine_ingestion.py +5 -0
- package/src/superlocalmemory/core/ingestion_command.py +36 -0
- package/src/superlocalmemory/core/materialization_control.py +20 -0
- package/src/superlocalmemory/core/ollama_embedder.py +11 -2
- package/src/superlocalmemory/core/recall_gate.py +27 -3
- package/src/superlocalmemory/core/remember_admission.py +14 -5
- package/src/superlocalmemory/core/store_pipeline.py +10 -0
- package/src/superlocalmemory/hooks/adapter_base.py +10 -3
- package/src/superlocalmemory/mcp/tools_core.py +32 -12
- package/src/superlocalmemory/optimize/proxy/capture.py +148 -30
- package/src/superlocalmemory/optimize/storage/db.py +6 -2
- package/src/superlocalmemory/server/unified_daemon.py +11 -2
- package/src/superlocalmemory/storage/admission_codec.py +10 -0
- package/src/superlocalmemory/storage/admission_journal.py +182 -67
- package/src/superlocalmemory/storage/embedding_migrator.py +27 -13
- package/src/superlocalmemory/storage/write_coordinator.py +68 -21
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
3
|
# Part of SuperLocalMemory V3 | https://qualixar.com
|
|
4
4
|
|
|
5
|
-
"""Separate
|
|
5
|
+
"""Separate journal for durable remember admission.
|
|
6
6
|
|
|
7
7
|
The journal is intentionally not a memory store. Its only mutable state is a
|
|
8
8
|
small encrypted replay command and the canonical receipt associated with it.
|
|
9
|
+
Durable prepare and terminal transitions are FULL-synchronous; the advisory
|
|
10
|
+
dispatched marker may use NORMAL because both replay states are equivalent.
|
|
9
11
|
Canonical facts, FTS, graph, vectors, and model work stay outside this module.
|
|
10
12
|
"""
|
|
11
13
|
|
|
@@ -31,6 +33,7 @@ from cryptography.exceptions import InvalidTag
|
|
|
31
33
|
_MAX_COMMAND_BYTES = 256 * 1024
|
|
32
34
|
_MAX_RECEIPT_BYTES = 16 * 1024
|
|
33
35
|
_MAX_METADATA_DEPTH = 8
|
|
36
|
+
_MAX_PREOPENED_WRITE_CONNECTIONS = 8
|
|
34
37
|
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
|
|
35
38
|
_STATES = frozenset({"prepared", "dispatched", "committed", "rejected"})
|
|
36
39
|
|
|
@@ -213,10 +216,13 @@ class AdmissionJournal:
|
|
|
213
216
|
self._codec = codec
|
|
214
217
|
# The daemon is the sole journal owner, but many HTTP/MCP request
|
|
215
218
|
# threads can prepare and transition entries concurrently. SQLite has
|
|
216
|
-
# one writer, so admit those tiny
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
+
# one writer, so admit those tiny journal transactions through one
|
|
220
|
+
# process-local lock instead of letting BEGIN IMMEDIATE race and leak
|
|
221
|
+
# SQLITE_BUSY to remember callers.
|
|
219
222
|
self._write_lock = threading.RLock()
|
|
223
|
+
self._write_connection_slots = threading.BoundedSemaphore(
|
|
224
|
+
_MAX_PREOPENED_WRITE_CONNECTIONS
|
|
225
|
+
)
|
|
220
226
|
self._initialize()
|
|
221
227
|
|
|
222
228
|
def prepare(
|
|
@@ -248,36 +254,59 @@ class AdmissionJournal:
|
|
|
248
254
|
now = _now_ms()
|
|
249
255
|
journal_id = uuid.uuid4().hex
|
|
250
256
|
|
|
251
|
-
|
|
257
|
+
# Keep existing retries read-only and outside mutation admission. A
|
|
258
|
+
# miss closes this short-lived reader before entering the bounded
|
|
259
|
+
# write lane, where the key is rechecked transactionally.
|
|
260
|
+
with self._read_connection(deadline=deadline) as conn:
|
|
252
261
|
existing = conn.execute(
|
|
253
262
|
"SELECT * FROM admission_journal "
|
|
254
263
|
"WHERE profile_id=? AND idempotency_key=?",
|
|
255
264
|
(request.profile_id, request.idempotency_key),
|
|
256
265
|
).fetchone()
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
266
|
+
if existing is not None:
|
|
267
|
+
entry = self._entry_from_row(existing)
|
|
268
|
+
if entry.request_hash != request_hash:
|
|
269
|
+
raise IdempotencyConflict(
|
|
270
|
+
"idempotency key belongs to a different immutable request"
|
|
271
|
+
)
|
|
272
|
+
return entry
|
|
273
|
+
|
|
274
|
+
with self._write_connection_slot(deadline=deadline):
|
|
275
|
+
remaining = _remaining_seconds(deadline)
|
|
276
|
+
with self._connection(timeout=remaining) as conn:
|
|
277
|
+
with self._write_slot(deadline=deadline):
|
|
278
|
+
with self._sqlite_transaction(conn, deadline=deadline):
|
|
279
|
+
existing = conn.execute(
|
|
280
|
+
"SELECT * FROM admission_journal "
|
|
281
|
+
"WHERE profile_id=? AND idempotency_key=?",
|
|
282
|
+
(request.profile_id, request.idempotency_key),
|
|
283
|
+
).fetchone()
|
|
284
|
+
if existing is not None:
|
|
285
|
+
entry = self._entry_from_row(existing)
|
|
286
|
+
if entry.request_hash != request_hash:
|
|
287
|
+
raise IdempotencyConflict(
|
|
288
|
+
"idempotency key belongs to a different immutable request"
|
|
289
|
+
)
|
|
290
|
+
return entry
|
|
291
|
+
conn.execute(
|
|
292
|
+
"INSERT INTO admission_journal "
|
|
293
|
+
"(journal_id, idempotency_key, request_hash, profile_id, "
|
|
294
|
+
"command_json, state, created_at_ms, updated_at_ms) "
|
|
295
|
+
"VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)",
|
|
296
|
+
(
|
|
297
|
+
journal_id,
|
|
298
|
+
request.idempotency_key,
|
|
299
|
+
request_hash,
|
|
300
|
+
request.profile_id,
|
|
301
|
+
command_json,
|
|
302
|
+
now,
|
|
303
|
+
now,
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
row = conn.execute(
|
|
307
|
+
"SELECT * FROM admission_journal WHERE journal_id=?",
|
|
308
|
+
(journal_id,),
|
|
309
|
+
).fetchone()
|
|
281
310
|
assert row is not None
|
|
282
311
|
return self._entry_from_row(row)
|
|
283
312
|
|
|
@@ -319,16 +348,22 @@ class AdmissionJournal:
|
|
|
319
348
|
journal_id: str,
|
|
320
349
|
*,
|
|
321
350
|
deadline: float | None = None,
|
|
351
|
+
known_prepared: bool = False,
|
|
322
352
|
) -> AdmissionEntry:
|
|
323
353
|
# A concurrent retry may observe ``prepared`` and then lose the race to
|
|
324
354
|
# another caller that commits the same idempotent command. Treat that
|
|
325
355
|
# terminal state as a successful no-op so the retry can return the
|
|
326
356
|
# canonical receipt instead of surfacing a false transition failure.
|
|
357
|
+
if not known_prepared:
|
|
358
|
+
existing = self._get_entry(journal_id, deadline=deadline)
|
|
359
|
+
if existing.state in {"dispatched", "committed"}:
|
|
360
|
+
return existing
|
|
327
361
|
return self._transition(
|
|
328
362
|
journal_id,
|
|
329
363
|
target="dispatched",
|
|
330
364
|
allowed={"prepared", "dispatched", "committed"},
|
|
331
365
|
deadline=deadline,
|
|
366
|
+
full_sync=False,
|
|
332
367
|
)
|
|
333
368
|
|
|
334
369
|
def mark_rejected(
|
|
@@ -363,6 +398,9 @@ class AdmissionJournal:
|
|
|
363
398
|
raise ValueError("receipt operation_id must be a string")
|
|
364
399
|
if commit_sequence is not None and not isinstance(commit_sequence, int):
|
|
365
400
|
raise ValueError("receipt commit_sequence must be an integer")
|
|
401
|
+
existing = self._get_entry(journal_id, deadline=deadline)
|
|
402
|
+
if existing.state == "committed":
|
|
403
|
+
return existing
|
|
366
404
|
return self._transition(
|
|
367
405
|
journal_id,
|
|
368
406
|
target="committed",
|
|
@@ -374,24 +412,13 @@ class AdmissionJournal:
|
|
|
374
412
|
)
|
|
375
413
|
|
|
376
414
|
def get(self, journal_id: str) -> AdmissionEntry:
|
|
377
|
-
|
|
378
|
-
row = conn.execute(
|
|
379
|
-
"SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
|
|
380
|
-
).fetchone()
|
|
381
|
-
if row is None:
|
|
382
|
-
raise KeyError(journal_id)
|
|
383
|
-
return self._entry_from_row(row)
|
|
415
|
+
return self._get_entry(journal_id)
|
|
384
416
|
|
|
385
417
|
def get_by_idempotency_key(
|
|
386
418
|
self, profile_id: str, idempotency_key: str
|
|
387
419
|
) -> AdmissionEntry | None:
|
|
388
420
|
"""Return a profile-scoped retry record, never a cross-profile match."""
|
|
389
|
-
|
|
390
|
-
row = conn.execute(
|
|
391
|
-
"SELECT * FROM admission_journal WHERE profile_id=? AND idempotency_key=?",
|
|
392
|
-
(profile_id, idempotency_key),
|
|
393
|
-
).fetchone()
|
|
394
|
-
return self._entry_from_row(row) if row is not None else None
|
|
421
|
+
return self._get_by_idempotency_key(profile_id, idempotency_key)
|
|
395
422
|
|
|
396
423
|
def count(self) -> int:
|
|
397
424
|
with self._read_connection() as conn:
|
|
@@ -451,8 +478,9 @@ class AdmissionJournal:
|
|
|
451
478
|
operation_id: str | None = None,
|
|
452
479
|
commit_sequence: int | None = None,
|
|
453
480
|
deadline: float | None = None,
|
|
481
|
+
full_sync: bool = True,
|
|
454
482
|
) -> AdmissionEntry:
|
|
455
|
-
with self._write_transaction(deadline=deadline) as conn:
|
|
483
|
+
with self._write_transaction(deadline=deadline, full_sync=full_sync) as conn:
|
|
456
484
|
row = conn.execute(
|
|
457
485
|
"SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
|
|
458
486
|
).fetchone()
|
|
@@ -490,6 +518,34 @@ class AdmissionJournal:
|
|
|
490
518
|
assert updated is not None
|
|
491
519
|
return self._entry_from_row(updated)
|
|
492
520
|
|
|
521
|
+
def _get_entry(
|
|
522
|
+
self,
|
|
523
|
+
journal_id: str,
|
|
524
|
+
*,
|
|
525
|
+
deadline: float | None = None,
|
|
526
|
+
) -> AdmissionEntry:
|
|
527
|
+
with self._read_connection(deadline=deadline) as conn:
|
|
528
|
+
row = conn.execute(
|
|
529
|
+
"SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
|
|
530
|
+
).fetchone()
|
|
531
|
+
if row is None:
|
|
532
|
+
raise KeyError(journal_id)
|
|
533
|
+
return self._entry_from_row(row)
|
|
534
|
+
|
|
535
|
+
def _get_by_idempotency_key(
|
|
536
|
+
self,
|
|
537
|
+
profile_id: str,
|
|
538
|
+
idempotency_key: str,
|
|
539
|
+
*,
|
|
540
|
+
deadline: float | None = None,
|
|
541
|
+
) -> AdmissionEntry | None:
|
|
542
|
+
with self._read_connection(deadline=deadline) as conn:
|
|
543
|
+
row = conn.execute(
|
|
544
|
+
"SELECT * FROM admission_journal WHERE profile_id=? AND idempotency_key=?",
|
|
545
|
+
(profile_id, idempotency_key),
|
|
546
|
+
).fetchone()
|
|
547
|
+
return self._entry_from_row(row) if row is not None else None
|
|
548
|
+
|
|
493
549
|
def _initialize(self) -> None:
|
|
494
550
|
with self._connection() as conn:
|
|
495
551
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
@@ -503,8 +559,52 @@ class AdmissionJournal:
|
|
|
503
559
|
self,
|
|
504
560
|
*,
|
|
505
561
|
deadline: float | None = None,
|
|
562
|
+
full_sync: bool = True,
|
|
506
563
|
) -> Generator[sqlite3.Connection, None, None]:
|
|
507
564
|
"""Serialize and atomically commit one deadline-bounded mutation."""
|
|
565
|
+
with self._write_connection_slot(deadline=deadline):
|
|
566
|
+
remaining = _remaining_seconds(deadline)
|
|
567
|
+
with self._connection(timeout=remaining) as conn:
|
|
568
|
+
with self._write_slot(deadline=deadline):
|
|
569
|
+
if not full_sync:
|
|
570
|
+
# ``dispatched`` is advisory: both prepared and dispatched
|
|
571
|
+
# entries replay through the same idempotent coordinator.
|
|
572
|
+
# NORMAL avoids a second foreground fsync after the durable
|
|
573
|
+
# FULL-synchronous prepare; a power loss can at worst
|
|
574
|
+
# restore the safe prepared state.
|
|
575
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
576
|
+
with self._sqlite_transaction(conn, deadline=deadline):
|
|
577
|
+
yield conn
|
|
578
|
+
|
|
579
|
+
@contextmanager
|
|
580
|
+
def _write_connection_slot(
|
|
581
|
+
self,
|
|
582
|
+
*,
|
|
583
|
+
deadline: float | None = None,
|
|
584
|
+
) -> Generator[None, None, None]:
|
|
585
|
+
"""Bound SQLite handles retained by mutations queued for the writer."""
|
|
586
|
+
if deadline is None:
|
|
587
|
+
acquired = self._write_connection_slots.acquire()
|
|
588
|
+
else:
|
|
589
|
+
acquired = self._write_connection_slots.acquire(
|
|
590
|
+
timeout=max(0.0, deadline - time.monotonic())
|
|
591
|
+
)
|
|
592
|
+
if not acquired:
|
|
593
|
+
raise AdmissionJournalUnavailable(
|
|
594
|
+
"admission journal deadline expired waiting for a connection"
|
|
595
|
+
)
|
|
596
|
+
try:
|
|
597
|
+
yield
|
|
598
|
+
finally:
|
|
599
|
+
self._write_connection_slots.release()
|
|
600
|
+
|
|
601
|
+
@contextmanager
|
|
602
|
+
def _write_slot(
|
|
603
|
+
self,
|
|
604
|
+
*,
|
|
605
|
+
deadline: float | None = None,
|
|
606
|
+
) -> Generator[None, None, None]:
|
|
607
|
+
"""Acquire the process-local SQLite writer slot within the caller budget."""
|
|
508
608
|
if deadline is None:
|
|
509
609
|
acquired = self._write_lock.acquire()
|
|
510
610
|
else:
|
|
@@ -516,33 +616,48 @@ class AdmissionJournal:
|
|
|
516
616
|
"admission journal deadline expired waiting for its writer"
|
|
517
617
|
)
|
|
518
618
|
try:
|
|
519
|
-
|
|
520
|
-
with self._connection(timeout=remaining) as conn:
|
|
521
|
-
try:
|
|
522
|
-
conn.execute("BEGIN IMMEDIATE")
|
|
523
|
-
if deadline is not None and time.monotonic() >= deadline:
|
|
524
|
-
raise AdmissionJournalUnavailable(
|
|
525
|
-
"admission journal deadline expired before its mutation"
|
|
526
|
-
)
|
|
527
|
-
yield conn
|
|
528
|
-
if deadline is not None and time.monotonic() >= deadline:
|
|
529
|
-
raise AdmissionJournalUnavailable(
|
|
530
|
-
"admission journal deadline expired during its mutation"
|
|
531
|
-
)
|
|
532
|
-
conn.commit()
|
|
533
|
-
except sqlite3.OperationalError as exc:
|
|
534
|
-
conn.rollback()
|
|
535
|
-
if _is_sqlite_busy(exc):
|
|
536
|
-
raise AdmissionJournalUnavailable(
|
|
537
|
-
"admission journal is busy beyond its caller deadline"
|
|
538
|
-
) from exc
|
|
539
|
-
raise
|
|
540
|
-
except BaseException:
|
|
541
|
-
conn.rollback()
|
|
542
|
-
raise
|
|
619
|
+
yield
|
|
543
620
|
finally:
|
|
544
621
|
self._write_lock.release()
|
|
545
622
|
|
|
623
|
+
@contextmanager
|
|
624
|
+
def _sqlite_transaction(
|
|
625
|
+
self,
|
|
626
|
+
conn: sqlite3.Connection,
|
|
627
|
+
*,
|
|
628
|
+
deadline: float | None = None,
|
|
629
|
+
) -> Generator[None, None, None]:
|
|
630
|
+
"""Commit or roll back one SQLite mutation on an already-open connection."""
|
|
631
|
+
try:
|
|
632
|
+
# A reused prepare connection may have waited for the local writer
|
|
633
|
+
# slot after its optimistic read. Refresh SQLite's own wait budget
|
|
634
|
+
# immediately before BEGIN so combined local and external
|
|
635
|
+
# contention cannot exceed the caller's original deadline.
|
|
636
|
+
remaining = _remaining_seconds(deadline)
|
|
637
|
+
busy_timeout_ms = max(1, int(remaining * 1_000))
|
|
638
|
+
conn.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
|
|
639
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
640
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
641
|
+
raise AdmissionJournalUnavailable(
|
|
642
|
+
"admission journal deadline expired before its mutation"
|
|
643
|
+
)
|
|
644
|
+
yield
|
|
645
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
646
|
+
raise AdmissionJournalUnavailable(
|
|
647
|
+
"admission journal deadline expired during its mutation"
|
|
648
|
+
)
|
|
649
|
+
conn.commit()
|
|
650
|
+
except sqlite3.OperationalError as exc:
|
|
651
|
+
conn.rollback()
|
|
652
|
+
if _is_sqlite_busy(exc):
|
|
653
|
+
raise AdmissionJournalUnavailable(
|
|
654
|
+
"admission journal is busy beyond its caller deadline"
|
|
655
|
+
) from exc
|
|
656
|
+
raise
|
|
657
|
+
except BaseException:
|
|
658
|
+
conn.rollback()
|
|
659
|
+
raise
|
|
660
|
+
|
|
546
661
|
@contextmanager
|
|
547
662
|
def _read_connection(
|
|
548
663
|
self,
|
|
@@ -412,29 +412,43 @@ def backfill_missing_embeddings(
|
|
|
412
412
|
continue
|
|
413
413
|
try:
|
|
414
414
|
embedding_json = json.dumps(vec)
|
|
415
|
-
# Mirror run_embedding_migration's write path exactly.
|
|
416
|
-
db.execute(
|
|
417
|
-
"UPDATE atomic_facts SET embedding = ? WHERE fact_id = ?",
|
|
418
|
-
(embedding_json, fid),
|
|
419
|
-
)
|
|
420
415
|
# Metadata is not an independent record: it is the pointer to
|
|
421
416
|
# a sqlite-vec row. Creating it before the vector payload leaves
|
|
422
417
|
# semantic recall permanently blind while reporting success.
|
|
423
418
|
# VectorStore owns the atomic pair and repairs legacy orphans.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
and not vector_store.upsert(
|
|
419
|
+
projection_written = False
|
|
420
|
+
if vector_store is not None and getattr(vector_store, "available", False):
|
|
421
|
+
projection_written = vector_store.upsert(
|
|
428
422
|
fid,
|
|
429
423
|
pid,
|
|
430
424
|
vec,
|
|
431
425
|
model_name=current_model,
|
|
432
426
|
)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
427
|
+
if not projection_written:
|
|
428
|
+
# Keep the canonical embedding NULL so the next
|
|
429
|
+
# bounded self-heal pass retries this fact. The old
|
|
430
|
+
# order wrote JSON first, permanently removed the fact
|
|
431
|
+
# from the backfill query, and silently stranded recall
|
|
432
|
+
# without its sqlite-vec projection.
|
|
433
|
+
logger.warning(
|
|
434
|
+
"backfill: vector projection failed for fact %s; "
|
|
435
|
+
"leaving it pending for retry",
|
|
436
|
+
fid[:16],
|
|
437
|
+
)
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
# Publish the canonical JSON only after the derived vector
|
|
442
|
+
# projection is durable. When sqlite-vec is unavailable,
|
|
443
|
+
# this remains the supported JSON-only fallback path.
|
|
444
|
+
db.execute(
|
|
445
|
+
"UPDATE atomic_facts SET embedding = ? WHERE fact_id = ?",
|
|
446
|
+
(embedding_json, fid),
|
|
437
447
|
)
|
|
448
|
+
except Exception:
|
|
449
|
+
if projection_written and vector_store is not None:
|
|
450
|
+
vector_store.delete(fid)
|
|
451
|
+
raise
|
|
438
452
|
embedded += 1
|
|
439
453
|
# Cooperative yield: release db._lock briefly so concurrent
|
|
440
454
|
# user writes can acquire it between facts. Without this,
|
|
@@ -223,6 +223,7 @@ class _Execution:
|
|
|
223
223
|
result: WriteResult | None = None
|
|
224
224
|
error: BaseException | None = None
|
|
225
225
|
cancelled: bool = False
|
|
226
|
+
commit_started: bool = False
|
|
226
227
|
|
|
227
228
|
|
|
228
229
|
class WriteCoordinator:
|
|
@@ -432,11 +433,7 @@ class WriteCoordinator:
|
|
|
432
433
|
deadline=time.monotonic() + timeout,
|
|
433
434
|
)
|
|
434
435
|
self._enqueue(item)
|
|
435
|
-
|
|
436
|
-
if not item.completion.wait(remaining):
|
|
437
|
-
with self._condition:
|
|
438
|
-
item.cancelled = True
|
|
439
|
-
raise WriteDeadlineExceededError("canonical write exceeded its caller deadline")
|
|
436
|
+
self._wait_for_completion(item)
|
|
440
437
|
if item.error is not None:
|
|
441
438
|
raise item.error
|
|
442
439
|
return item.rows or []
|
|
@@ -480,17 +477,31 @@ class WriteCoordinator:
|
|
|
480
477
|
command=command,
|
|
481
478
|
)
|
|
482
479
|
self._enqueue(item)
|
|
483
|
-
|
|
484
|
-
if not item.completion.wait(remaining):
|
|
485
|
-
with self._condition:
|
|
486
|
-
item.cancelled = True
|
|
487
|
-
raise WriteDeadlineExceededError("canonical write exceeded its caller deadline")
|
|
480
|
+
self._wait_for_completion(item)
|
|
488
481
|
if item.error is not None:
|
|
489
482
|
raise item.error
|
|
490
483
|
if item.result is None: # pragma: no cover - defensive worker invariant
|
|
491
484
|
raise WriteCoordinatorError("canonical command completed without a receipt")
|
|
492
485
|
return item.result
|
|
493
486
|
|
|
487
|
+
def _wait_for_completion(self, item: _Execution) -> None:
|
|
488
|
+
"""Cancel before commit or wait through an already-linearized commit."""
|
|
489
|
+
remaining = max(0.0, item.deadline - time.monotonic())
|
|
490
|
+
if item.completion.wait(remaining):
|
|
491
|
+
return
|
|
492
|
+
with self._condition:
|
|
493
|
+
if item.completion.is_set():
|
|
494
|
+
return
|
|
495
|
+
if not item.commit_started:
|
|
496
|
+
item.cancelled = True
|
|
497
|
+
raise WriteDeadlineExceededError(
|
|
498
|
+
"canonical write exceeded its caller deadline"
|
|
499
|
+
)
|
|
500
|
+
# Commit is the linearization point. Once it starts, return its real
|
|
501
|
+
# result instead of reporting an ambiguous timeout followed by a
|
|
502
|
+
# durable mutation.
|
|
503
|
+
item.completion.wait()
|
|
504
|
+
|
|
494
505
|
def _enqueue(self, item: _Execution) -> None:
|
|
495
506
|
with self._condition:
|
|
496
507
|
if self._stopping:
|
|
@@ -562,19 +573,54 @@ class WriteCoordinator:
|
|
|
562
573
|
item.error = WriteDeadlineExceededError("canonical write expired before execution")
|
|
563
574
|
item.completion.set()
|
|
564
575
|
return
|
|
576
|
+
remaining = max(0.0, item.deadline - time.monotonic())
|
|
577
|
+
if not self._process_write_lock.acquire(timeout=remaining):
|
|
578
|
+
item.error = WriteDeadlineExceededError(
|
|
579
|
+
"canonical write expired waiting for its process lock"
|
|
580
|
+
)
|
|
581
|
+
item.completion.set()
|
|
582
|
+
return
|
|
565
583
|
try:
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
584
|
+
remaining = item.deadline - time.monotonic()
|
|
585
|
+
if remaining <= 0:
|
|
586
|
+
item.error = WriteDeadlineExceededError(
|
|
587
|
+
"canonical write expired after waiting for its process lock"
|
|
588
|
+
)
|
|
589
|
+
return
|
|
590
|
+
remaining_ms = max(
|
|
591
|
+
1,
|
|
592
|
+
int(remaining * 1_000),
|
|
593
|
+
)
|
|
594
|
+
conn.execute(f"PRAGMA busy_timeout={remaining_ms}")
|
|
595
|
+
synchronous = "FULL" if item.lane is Lane.FOREGROUND else "NORMAL"
|
|
596
|
+
conn.execute(f"PRAGMA synchronous={synchronous}")
|
|
597
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
598
|
+
with self._condition:
|
|
599
|
+
if item.cancelled or time.monotonic() >= item.deadline:
|
|
600
|
+
item.error = WriteDeadlineExceededError(
|
|
601
|
+
"canonical write expired before its mutation"
|
|
602
|
+
)
|
|
603
|
+
if item.error is not None:
|
|
604
|
+
conn.rollback()
|
|
605
|
+
return
|
|
606
|
+
if item.command is not None:
|
|
607
|
+
item.result = self._execute_command(conn, item.command)
|
|
608
|
+
else:
|
|
609
|
+
if item.sql is None: # pragma: no cover - execution invariant
|
|
610
|
+
raise WriteCoordinatorError("missing coordinator SQL command")
|
|
611
|
+
cursor = conn.execute(item.sql, item.parameters)
|
|
612
|
+
item.rows = cursor.fetchall()
|
|
613
|
+
with self._condition:
|
|
614
|
+
if item.cancelled or time.monotonic() >= item.deadline:
|
|
615
|
+
item.error = WriteDeadlineExceededError(
|
|
616
|
+
"canonical write expired during its mutation"
|
|
617
|
+
)
|
|
572
618
|
else:
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
619
|
+
item.commit_started = True
|
|
620
|
+
if item.error is not None:
|
|
621
|
+
conn.rollback()
|
|
622
|
+
return
|
|
623
|
+
conn.commit()
|
|
578
624
|
except WriteCoordinatorError as exc:
|
|
579
625
|
conn.rollback()
|
|
580
626
|
item.error = exc
|
|
@@ -590,6 +636,7 @@ class WriteCoordinator:
|
|
|
590
636
|
item.error = WriteCoordinatorError("canonical write command failed")
|
|
591
637
|
item.error.__cause__ = exc
|
|
592
638
|
finally:
|
|
639
|
+
self._process_write_lock.release()
|
|
593
640
|
item.completion.set()
|
|
594
641
|
|
|
595
642
|
def _execute_command(self, conn: sqlite3.Connection, command: WriteCommand) -> WriteResult:
|