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.
Files changed (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/daemon.py +89 -16
  34. package/src/superlocalmemory/core/embeddings.py +90 -6
  35. package/src/superlocalmemory/core/engine_ingestion.py +5 -0
  36. package/src/superlocalmemory/core/ingestion_command.py +36 -0
  37. package/src/superlocalmemory/core/materialization_control.py +20 -0
  38. package/src/superlocalmemory/core/ollama_embedder.py +11 -2
  39. package/src/superlocalmemory/core/recall_gate.py +27 -3
  40. package/src/superlocalmemory/core/remember_admission.py +14 -5
  41. package/src/superlocalmemory/core/store_pipeline.py +10 -0
  42. package/src/superlocalmemory/hooks/adapter_base.py +10 -3
  43. package/src/superlocalmemory/mcp/tools_core.py +32 -12
  44. package/src/superlocalmemory/optimize/proxy/capture.py +148 -30
  45. package/src/superlocalmemory/optimize/storage/db.py +6 -2
  46. package/src/superlocalmemory/server/unified_daemon.py +11 -2
  47. package/src/superlocalmemory/storage/admission_codec.py +10 -0
  48. package/src/superlocalmemory/storage/admission_journal.py +182 -67
  49. package/src/superlocalmemory/storage/embedding_migrator.py +27 -13
  50. 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 FULL-synchronous journal for durable remember admission.
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 FULL-synchronous transactions through
217
- # one process-local lock instead of letting BEGIN IMMEDIATE race and
218
- # leak SQLITE_BUSY to remember callers.
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
- with self._write_transaction(deadline=deadline) as conn:
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
- if existing is not None:
258
- entry = self._entry_from_row(existing)
259
- if entry.request_hash != request_hash:
260
- raise IdempotencyConflict(
261
- "idempotency key belongs to a different immutable request"
262
- )
263
- return entry
264
- conn.execute(
265
- "INSERT INTO admission_journal "
266
- "(journal_id, idempotency_key, request_hash, profile_id, command_json, state, "
267
- "created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)",
268
- (
269
- journal_id,
270
- request.idempotency_key,
271
- request_hash,
272
- request.profile_id,
273
- command_json,
274
- now,
275
- now,
276
- ),
277
- )
278
- row = conn.execute(
279
- "SELECT * FROM admission_journal WHERE journal_id=?", (journal_id,)
280
- ).fetchone()
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
- with self._read_connection() as conn:
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
- with self._read_connection() as conn:
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
- remaining = _remaining_seconds(deadline)
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
- if (
425
- vector_store is not None
426
- and getattr(vector_store, "available", False)
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
- logger.warning(
435
- "backfill: vector projection failed for fact %s",
436
- fid[:16],
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
- remaining = max(0.0, item.deadline - time.monotonic())
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
- remaining = max(0.0, item.deadline - time.monotonic())
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
- with self._process_write_lock:
567
- synchronous = "FULL" if item.lane is Lane.FOREGROUND else "NORMAL"
568
- conn.execute(f"PRAGMA synchronous={synchronous}")
569
- conn.execute("BEGIN IMMEDIATE")
570
- if item.command is not None:
571
- item.result = self._execute_command(conn, item.command)
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
- if item.sql is None: # pragma: no cover - execution invariant
574
- raise WriteCoordinatorError("missing coordinator SQL command")
575
- cursor = conn.execute(item.sql, item.parameters)
576
- item.rows = cursor.fetchall()
577
- conn.commit()
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: