superlocalmemory 3.8.3 → 3.8.5

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 (93) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +3 -2
  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/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +67 -68
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +200 -31
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
@@ -0,0 +1,153 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Best-effort, coalesced deferred writes that must NOT block a read path.
6
+
7
+ Recall must be read-only on its hot path. Historically the entity resolver
8
+ stamped ``canonical_entities.last_seen`` inline during recall (an ``UPDATE``
9
+ that takes the write lock), so recall waited behind writers — the root of the
10
+ "recall is 8 s" regression. ``last_seen`` is consumed ONLY by the dashboard
11
+ (entities / graph "last seen" columns); it never feeds recall ranking, so it
12
+ can be written a moment later with zero quality loss.
13
+
14
+ This module records such touches in memory (instant, lock-free for the caller)
15
+ and flushes them from a single background thread in small coalesced batches.
16
+ Bursts for the same entity collapse to one UPDATE per flush. Failures are
17
+ swallowed — bookkeeping must never raise into a recall/ingest caller.
18
+
19
+ This is deliberately small and self-contained; it is the seed of the wider
20
+ single-writer queue (see WRITE-QUEUE-PLAN.md).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import queue
25
+ import threading
26
+
27
+ _FLUSH_INTERVAL_S = 2.0
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # General best-effort background writer (seed of the single-writer queue).
32
+ # For NON-ESSENTIAL bookkeeping writes that must never block a read/recall
33
+ # path (access logging, activation-cache warming, etc.). Fire-and-forget:
34
+ # jobs are dropped under extreme backpressure rather than blocking a caller.
35
+ # Substantive/durable writes (remember, materialize) do NOT use this — they
36
+ # get the durable single-writer queue (see WRITE-QUEUE-PLAN.md Stage 3).
37
+ # ---------------------------------------------------------------------------
38
+
39
+ _BG_MAXSIZE = 20000
40
+ _bg_queue: "queue.Queue" = queue.Queue(maxsize=_BG_MAXSIZE)
41
+ _bg_started = False
42
+ _bg_start_lock = threading.Lock()
43
+
44
+
45
+ def _bg_run() -> None:
46
+ while True:
47
+ fn = _bg_queue.get()
48
+ try:
49
+ fn()
50
+ except Exception:
51
+ # Best-effort: bookkeeping must never crash the writer thread.
52
+ pass
53
+ finally:
54
+ _bg_queue.task_done()
55
+
56
+
57
+ def _ensure_bg_thread() -> None:
58
+ global _bg_started
59
+ if _bg_started:
60
+ return
61
+ with _bg_start_lock:
62
+ if _bg_started:
63
+ return
64
+ threading.Thread(
65
+ target=_bg_run, name="slm-bg-writer", daemon=True
66
+ ).start()
67
+ _bg_started = True
68
+
69
+
70
+ def submit_background(fn) -> None:
71
+ """Run *fn* on the shared background writer. Fire-and-forget, best-effort.
72
+
73
+ Recall/read paths call this instead of writing inline, so they never wait
74
+ on the write lock. Under extreme backpressure the job is dropped (the
75
+ write was non-essential bookkeeping).
76
+ """
77
+ _ensure_bg_thread()
78
+ try:
79
+ _bg_queue.put_nowait(fn)
80
+ except queue.Full:
81
+ pass
82
+
83
+
84
+ class DeferredLastSeen:
85
+ """Coalescing background flusher for canonical_entities.last_seen."""
86
+
87
+ def __init__(self, db, interval_s: float = _FLUSH_INTERVAL_S) -> None:
88
+ self._db = db
89
+ self._interval = interval_s
90
+ self._pending: dict[tuple[str, str], str] = {}
91
+ self._lock = threading.Lock()
92
+ self._stop = threading.Event()
93
+ self._thread = threading.Thread(
94
+ target=self._run, name="slm-deferred-lastseen", daemon=True
95
+ )
96
+ self._thread.start()
97
+
98
+ def touch(self, entity_id: str, profile_id: str, ts: str) -> None:
99
+ """Record a last_seen update. Instant, coalesced, never blocks."""
100
+ with self._lock:
101
+ self._pending[(entity_id, profile_id)] = ts
102
+
103
+ def flush(self) -> int:
104
+ """Write all pending updates now. Returns count. Best-effort."""
105
+ with self._lock:
106
+ if not self._pending:
107
+ return 0
108
+ batch = self._pending
109
+ self._pending = {}
110
+ try:
111
+ # One transaction => one write-lock acquisition for the whole
112
+ # batch, off the recall/ingest thread.
113
+ with self._db.transaction():
114
+ for (entity_id, profile_id), ts in batch.items():
115
+ self._db.execute(
116
+ "UPDATE canonical_entities SET last_seen = ? "
117
+ "WHERE entity_id = ? AND profile_id = ?",
118
+ (ts, entity_id, profile_id),
119
+ )
120
+ return len(batch)
121
+ except Exception:
122
+ # Best-effort: drop this batch rather than raise into a caller.
123
+ return 0
124
+
125
+ def _run(self) -> None:
126
+ while not self._stop.wait(self._interval):
127
+ self.flush()
128
+
129
+ def stop(self) -> None:
130
+ self._stop.set()
131
+ self.flush()
132
+
133
+
134
+ _registry: dict[int, DeferredLastSeen] = {}
135
+ _registry_lock = threading.Lock()
136
+
137
+
138
+ def get_deferred_last_seen(db) -> DeferredLastSeen:
139
+ """Return the process-wide DeferredLastSeen flusher for *db* (lazy singleton)."""
140
+ key = id(db)
141
+ with _registry_lock:
142
+ writer = _registry.get(key)
143
+ if writer is None:
144
+ writer = DeferredLastSeen(db)
145
+ _registry[key] = writer
146
+ return writer
147
+
148
+
149
+ __all__ = [
150
+ "DeferredLastSeen",
151
+ "get_deferred_last_seen",
152
+ "submit_background",
153
+ ]
@@ -18,6 +18,8 @@ from __future__ import annotations
18
18
 
19
19
  import json
20
20
  import logging
21
+ import os as _os
22
+ import time
21
23
  from pathlib import Path
22
24
  from typing import TYPE_CHECKING, Any
23
25
 
@@ -33,6 +35,16 @@ logger = logging.getLogger(__name__)
33
35
  #: Default batch size for backfill_missing_embeddings.
34
36
  _BACKFILL_BATCH_SIZE = 50
35
37
 
38
+ #: Cooperative yield between per-fact writes in backfill_missing_embeddings.
39
+ #: After every fact's UPDATE + INSERT pair, the write-back loop releases
40
+ #: db._lock for this many seconds, giving concurrent user writes a guaranteed
41
+ #: acquisition window. Tunable via SLM_SELFHEAL_WRITE_DELAY_S; set to 0 to
42
+ #: disable (only do this on single-user dev databases with no concurrency).
43
+ #: Default 5 ms is imperceptible for humans but visible to the OS scheduler.
44
+ _SELFHEAL_WRITE_DELAY_S: float = float(
45
+ _os.environ.get("SLM_SELFHEAL_WRITE_DELAY_S", "0.005")
46
+ )
47
+
36
48
  #: Max characters embedded per fact during backfill. The embedding model
37
49
  #: (nomic-embed-text-v1.5) truncates at ~8192 tokens anyway, but a raw
38
50
  #: oversized document (observed up to 107 KB on a real DB) makes the shared
@@ -394,6 +406,13 @@ def backfill_missing_embeddings(
394
406
  (fid, pid, current_model, current_dim),
395
407
  )
396
408
  embedded += 1
409
+ # Cooperative yield: release db._lock briefly so concurrent
410
+ # user writes can acquire it between facts. Without this,
411
+ # the write-back loop holds db._lock in rapid succession
412
+ # (Python RLock has no fairness guarantee), potentially
413
+ # starving user POST /remember writes for seconds on large DBs.
414
+ if _SELFHEAL_WRITE_DELAY_S > 0:
415
+ time.sleep(_SELFHEAL_WRITE_DELAY_S)
397
416
  except Exception as exc:
398
417
  logger.warning(
399
418
  "backfill: failed to write fact %s: %s", fid[:16], exc
@@ -0,0 +1,119 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Canonical short-lived WRITE / READ connections for memory.db.
6
+
7
+ Why this exists
8
+ ---------------
9
+ memory.db is a WAL SQLite database with exactly one writer allowed at any
10
+ instant. Historically many subsystems opened their own bare
11
+ ``sqlite3.connect(...)`` and wrote without any coordination, which produced
12
+ two failure modes under real multi-agent load:
13
+
14
+ 1. **In-process races** — daemon worker threads racing each other at the WAL
15
+ layer, retrying ``SQLITE_BUSY`` until they time out.
16
+ 2. **Cross-process races** — Claude Code hooks and the ``slm ingest`` CLI run
17
+ as *separate OS processes* and write memory.db directly. An in-process
18
+ ``threading`` lock cannot serialise those; only SQLite's own
19
+ ``PRAGMA busy_timeout`` makes them WAIT for the writer instead of erroring.
20
+
21
+ ``memory_write()`` closes BOTH gaps with one helper that every writer uses:
22
+
23
+ * Acquires the process-level :func:`get_write_lock` (the OUTERMOST lock — see
24
+ ``write_lock.py``) so in-process writers serialise cleanly with **no**
25
+ ``SQLITE_BUSY`` spin.
26
+ * Opens the connection with ``PRAGMA busy_timeout`` so that a *different
27
+ process* (hook / CLI) writing at the same instant WAITS rather than failing.
28
+ * Commits on success, rolls back on error, always closes — a short critical
29
+ section.
30
+
31
+ The single hard rule for callers
32
+ ---------------------------------
33
+ **Never hold this connection across a slow operation** — no embedding /
34
+ network call / large unbounded transaction inside the ``with`` block. Do the
35
+ slow work first, then open ``memory_write()`` only for the fast INSERT/UPDATE.
36
+ Holding the writer across a multi-second op is exactly what starves everyone
37
+ else (SQLITE_BUSY after retries). Batch large writes into bounded chunks that
38
+ each commit quickly.
39
+
40
+ Reads
41
+ -----
42
+ Use :func:`memory_read` for read-only access. WAL allows concurrent readers
43
+ without blocking the writer, so reads do NOT take the write lock — but they
44
+ still get ``busy_timeout`` for robustness during the brief checkpoint window.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import os
49
+ import sqlite3
50
+ from contextlib import contextmanager
51
+ from pathlib import Path
52
+ from typing import Generator
53
+
54
+ from superlocalmemory.storage.write_lock import get_write_lock
55
+
56
+
57
+ def _busy_timeout_ms() -> int:
58
+ """Busy-timeout in ms, env-overridable, matching DatabaseManager's default.
59
+
60
+ Kept in sync with ``storage/database.py::_BUSY_TIMEOUT_MS`` (env
61
+ ``SLM_DB_BUSY_TIMEOUT_MS``, default 10_000) so every memory.db connection
62
+ in the process — and in hook/CLI child processes that import this — waits
63
+ the same amount for the single writer.
64
+ """
65
+ try:
66
+ return max(0, int(os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
67
+ except (TypeError, ValueError):
68
+ return 10000
69
+
70
+
71
+ @contextmanager
72
+ def memory_write(db_path: str | Path) -> Generator[sqlite3.Connection, None, None]:
73
+ """Yield a serialised, busy-timeout-guarded WRITE connection to memory.db.
74
+
75
+ Acquires the process write lock (outermost), opens a short-lived
76
+ connection with ``busy_timeout``, commits on success / rolls back on
77
+ error, and always closes.
78
+
79
+ HARD RULE: keep the ``with`` block short — never embed / call the network /
80
+ run an unbounded transaction while holding it.
81
+ """
82
+ ms = _busy_timeout_ms()
83
+ lock = get_write_lock(db_path)
84
+ with lock:
85
+ conn = sqlite3.connect(str(db_path), timeout=ms / 1000.0)
86
+ try:
87
+ conn.execute(f"PRAGMA busy_timeout={ms}")
88
+ conn.row_factory = sqlite3.Row
89
+ yield conn
90
+ conn.commit()
91
+ except Exception:
92
+ try:
93
+ conn.rollback()
94
+ except Exception:
95
+ pass
96
+ raise
97
+ finally:
98
+ conn.close()
99
+
100
+
101
+ @contextmanager
102
+ def memory_read(db_path: str | Path) -> Generator[sqlite3.Connection, None, None]:
103
+ """Yield a read-only-usage connection with ``busy_timeout`` (no write lock).
104
+
105
+ WAL permits concurrent readers, so this deliberately does NOT take the
106
+ write lock. ``busy_timeout`` still applies so a read issued during the
107
+ brief WAL checkpoint window waits rather than erroring.
108
+ """
109
+ ms = _busy_timeout_ms()
110
+ conn = sqlite3.connect(str(db_path), timeout=ms / 1000.0)
111
+ try:
112
+ conn.execute(f"PRAGMA busy_timeout={ms}")
113
+ conn.row_factory = sqlite3.Row
114
+ yield conn
115
+ finally:
116
+ conn.close()
117
+
118
+
119
+ __all__ = ["memory_write", "memory_read"]
@@ -122,6 +122,9 @@ from superlocalmemory.storage.migrations import (
122
122
  from superlocalmemory.storage.migrations import (
123
123
  M030_entity_explorer_indexes as _M030,
124
124
  )
125
+ from superlocalmemory.storage.migrations import (
126
+ M031_dead_letter_operations as _M031,
127
+ )
125
128
 
126
129
  # Map migration name → module (used for the optional ``verify(conn)`` hook
127
130
  # that lets the runner detect "already applied" state when an idempotent
@@ -156,6 +159,7 @@ _MODULES = {
156
159
  _M028.NAME: _M028,
157
160
  _M029.NAME: _M029,
158
161
  _M030.NAME: _M030,
162
+ _M031.NAME: _M031,
159
163
  }
160
164
 
161
165
  logger = logging.getLogger(__name__)
@@ -216,6 +220,9 @@ MIGRATIONS: list[Migration] = [
216
220
  Migration(name=_M024.NAME, db_target="memory", ddl=_M024.DDL),
217
221
  Migration(name=_M019.NAME, db_target="memory", ddl=_M019.DDL,
218
222
  dependencies=(_M018.NAME,)),
223
+ # M031 creates dead_letter_operations — standalone table, no FK to engine-
224
+ # bootstrapped tables, so it can run during apply_all (before engine init).
225
+ Migration(name=_M031.NAME, db_target="memory", ddl=_M031.DDL),
219
226
  # M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
220
227
  ]
221
228
 
@@ -101,17 +101,30 @@ def _now() -> str:
101
101
  return datetime.now(UTC).isoformat()
102
102
 
103
103
 
104
- def _connect(db_path: Path) -> sqlite3.Connection:
105
- conn = sqlite3.connect(str(db_path), timeout=5)
104
+ def _connect_read(db_path: Path) -> sqlite3.Connection:
105
+ """Read-only connection with system-default busy_timeout.
106
+
107
+ Concurrency fix (v3.8.4): busy_timeout raised from 5 s to match the
108
+ system default (10 s, env SLM_DB_BUSY_TIMEOUT_MS). Timeout raised from
109
+ 5 s to 10 s for the same reason. Write connections now go through
110
+ memory_write() so they acquire get_write_lock() and never use this helper.
111
+ """
112
+ import os
113
+
114
+ try:
115
+ ms = max(0, int(os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
116
+ except (TypeError, ValueError):
117
+ ms = 10000
118
+ conn = sqlite3.connect(str(db_path), timeout=ms / 1000.0)
106
119
  conn.row_factory = sqlite3.Row
107
- conn.execute("PRAGMA busy_timeout=5000")
120
+ conn.execute(f"PRAGMA busy_timeout={ms}")
108
121
  conn.execute("PRAGMA foreign_keys=ON")
109
122
  return conn
110
123
 
111
124
 
112
125
  def get_repair_status(db_path: Path) -> dict[str, int | str]:
113
126
  """Read durable backfill progress without inferring it from schema."""
114
- conn = _connect(Path(db_path))
127
+ conn = _connect_read(Path(db_path))
115
128
  try:
116
129
  row = conn.execute(
117
130
  "SELECT state,target_fact_rowid,last_fact_rowid,scanned,inserted,"
@@ -142,58 +155,70 @@ def _entity_ids(raw: object) -> tuple[str, ...]:
142
155
 
143
156
 
144
157
  def _repair_batch(conn: sqlite3.Connection, batch_size: int) -> dict[str, int | bool]:
145
- conn.execute("BEGIN IMMEDIATE")
146
- try:
147
- status = conn.execute(
148
- "SELECT last_fact_rowid,target_fact_rowid "
149
- "FROM fact_entity_association_repair_state "
150
- "WHERE repair_key='historical-backfill'"
151
- ).fetchone()
152
- cursor = int(status["last_fact_rowid"] or 0)
153
- target = int(status["target_fact_rowid"])
154
- rows = conn.execute(
155
- "SELECT rowid,fact_id,profile_id,canonical_entities_json "
156
- "FROM atomic_facts WHERE rowid>? AND rowid<=? "
157
- "ORDER BY rowid LIMIT ?",
158
- (cursor, target, batch_size),
159
- ).fetchall()
160
- if not rows:
161
- conn.execute(
162
- "UPDATE fact_entity_association_repair_state "
163
- "SET state='complete',last_error='',updated_at=? "
164
- "WHERE repair_key='historical-backfill'",
165
- (_now(),),
166
- )
167
- conn.commit()
168
- return {"scanned": 0, "inserted": 0, "complete": True}
169
- inserted = 0
170
- for row in rows:
171
- for entity_id in _entity_ids(row["canonical_entities_json"]):
172
- result = conn.execute(
173
- "INSERT OR IGNORE INTO fact_entity_associations "
174
- "(profile_id,fact_id,entity_id,first_operation_id,"
175
- "count_applied) "
176
- "SELECT ?,?,?,?,? FROM canonical_entities "
177
- "WHERE profile_id=? AND entity_id=?",
178
- (
179
- row["profile_id"], row["fact_id"], entity_id,
180
- "migration-backfill", 0,
181
- row["profile_id"], entity_id,
182
- ),
183
- )
184
- inserted += max(0, result.rowcount)
158
+ """Execute one backfill batch on *conn*.
159
+
160
+ Concurrency fix (v3.8.4): caller MUST hold the write lock via
161
+ memory_write() before calling this function. The explicit
162
+ ``conn.execute("BEGIN IMMEDIATE")`` has been removed because:
163
+
164
+ * memory_write() already acquired get_write_lock() (Python-level
165
+ serialisation) no other in-process writer can start.
166
+ * SQLite's implicit deferred transaction is promoted to a write
167
+ transaction on the first INSERT, which is equivalent to BEGIN
168
+ IMMEDIATE for in-process serialisation.
169
+ * The explicit BEGIN IMMEDIATE previously bypassed get_write_lock()
170
+ and would retry at the SQLite layer only (busy_timeout=5 s, now
171
+ 10 s) without respecting the Python-level write lock order.
172
+
173
+ Transaction boundary (commit/rollback) is managed by memory_write().
174
+ """
175
+ status = conn.execute(
176
+ "SELECT last_fact_rowid,target_fact_rowid "
177
+ "FROM fact_entity_association_repair_state "
178
+ "WHERE repair_key='historical-backfill'"
179
+ ).fetchone()
180
+ if status is None:
181
+ return {"scanned": 0, "inserted": 0, "complete": True}
182
+ cursor = int(status["last_fact_rowid"] or 0)
183
+ target = int(status["target_fact_rowid"])
184
+ rows = conn.execute(
185
+ "SELECT rowid,fact_id,profile_id,canonical_entities_json "
186
+ "FROM atomic_facts WHERE rowid>? AND rowid<=? "
187
+ "ORDER BY rowid LIMIT ?",
188
+ (cursor, target, batch_size),
189
+ ).fetchall()
190
+ if not rows:
185
191
  conn.execute(
186
- "UPDATE fact_entity_association_repair_state SET "
187
- "state='running',last_fact_rowid=?,scanned=scanned+?,"
188
- "inserted=inserted+?,last_error='',updated_at=? "
192
+ "UPDATE fact_entity_association_repair_state "
193
+ "SET state='complete',last_error='',updated_at=? "
189
194
  "WHERE repair_key='historical-backfill'",
190
- (int(rows[-1]["rowid"]), len(rows), inserted, _now()),
195
+ (_now(),),
191
196
  )
192
- conn.commit()
193
- return {"scanned": len(rows), "inserted": inserted, "complete": False}
194
- except Exception:
195
- conn.rollback()
196
- raise
197
+ return {"scanned": 0, "inserted": 0, "complete": True}
198
+ inserted = 0
199
+ for row in rows:
200
+ for entity_id in _entity_ids(row["canonical_entities_json"]):
201
+ result = conn.execute(
202
+ "INSERT OR IGNORE INTO fact_entity_associations "
203
+ "(profile_id,fact_id,entity_id,first_operation_id,"
204
+ "count_applied) "
205
+ "SELECT ?,?,?,?,? FROM canonical_entities "
206
+ "WHERE profile_id=? AND entity_id=?",
207
+ (
208
+ row["profile_id"], row["fact_id"], entity_id,
209
+ "migration-backfill", 0,
210
+ row["profile_id"], entity_id,
211
+ ),
212
+ )
213
+ inserted += max(0, result.rowcount)
214
+ conn.execute(
215
+ "UPDATE fact_entity_association_repair_state SET "
216
+ "state='running',last_fact_rowid=?,scanned=scanned+?,"
217
+ "inserted=inserted+?,last_error='',updated_at=? "
218
+ "WHERE repair_key='historical-backfill'",
219
+ (int(rows[-1]["rowid"]), len(rows), inserted, _now()),
220
+ )
221
+ return {"scanned": len(rows), "inserted": inserted, "complete": False}
197
222
 
198
223
 
199
224
  def repair_fact_entity_associations(
@@ -202,34 +227,44 @@ def repair_fact_entity_associations(
202
227
  batch_size: int = 250,
203
228
  max_batches: int = 1,
204
229
  ) -> dict[str, int | bool]:
205
- """Run bounded, restartable short-transaction backfill batches."""
230
+ """Run bounded, restartable short-transaction backfill batches.
231
+
232
+ Concurrency fix (v3.8.4): each batch is now wrapped in memory_write()
233
+ which acquires get_write_lock() (process-level write serialisation) and
234
+ sets the system-default busy_timeout (10 s) before opening the SQLite
235
+ connection. Previously the loop used a single raw _connect() (timeout=5,
236
+ busy_timeout=5000) without get_write_lock(), which bypassed in-process
237
+ write serialisation and could cause SQLITE_BUSY after only 5 s.
238
+ """
239
+ from superlocalmemory.storage.memory_write import memory_write
240
+
206
241
  if batch_size < 1 or max_batches < 1:
207
242
  raise ValueError("batch_size and max_batches must be positive")
208
- totals = {"scanned": 0, "inserted": 0, "complete": False}
209
- conn = _connect(Path(db_path))
210
- try:
211
- for _ in range(max_batches):
212
- result = _repair_batch(conn, batch_size)
213
- totals["scanned"] += int(result["scanned"])
214
- totals["inserted"] += int(result["inserted"])
215
- totals["complete"] = bool(result["complete"])
216
- if totals["complete"]:
217
- break
218
- return totals
219
- except sqlite3.Error as exc:
243
+ totals: dict[str, int | bool] = {"scanned": 0, "inserted": 0, "complete": False}
244
+ for _ in range(max_batches):
220
245
  try:
221
- conn.execute(
222
- "UPDATE fact_entity_association_repair_state SET "
223
- "state='retrying',last_error=?,updated_at=? "
224
- "WHERE repair_key='historical-backfill'",
225
- (type(exc).__name__, _now()),
226
- )
227
- conn.commit()
228
- except sqlite3.Error:
229
- pass
230
- raise
231
- finally:
232
- conn.close()
246
+ with memory_write(Path(db_path)) as conn:
247
+ conn.execute("PRAGMA foreign_keys=ON")
248
+ result = _repair_batch(conn, batch_size)
249
+ totals["scanned"] += int(result["scanned"])
250
+ totals["inserted"] += int(result["inserted"])
251
+ totals["complete"] = bool(result["complete"])
252
+ except sqlite3.Error as exc:
253
+ # On error, record the retrying state in a separate short write.
254
+ try:
255
+ with memory_write(Path(db_path)) as econn:
256
+ econn.execute(
257
+ "UPDATE fact_entity_association_repair_state SET "
258
+ "state='retrying',last_error=?,updated_at=? "
259
+ "WHERE repair_key='historical-backfill'",
260
+ (type(exc).__name__, _now()),
261
+ )
262
+ except sqlite3.Error:
263
+ pass
264
+ raise
265
+ if totals["complete"]:
266
+ break
267
+ return totals
233
268
 
234
269
 
235
270
  def verify(conn: sqlite3.Connection) -> bool:
@@ -0,0 +1,80 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """M031 — dead-letter queue for exhausted ingestion operations (Fix E, issue #77).
6
+
7
+ Additive migration: creates dead_letter_operations if not present.
8
+ Existing rows in ingestion_operations are unaffected.
9
+
10
+ When an M018 ingestion operation exhausts _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS
11
+ (10) it previously remained silently in FAILED state — invisible to operators and
12
+ unreachable by the materialiser. This table gives operators a persistent,
13
+ inspectable record of every poisoned operation: original content, error, attempt
14
+ count, timestamps, and profile scope.
15
+
16
+ Schema design:
17
+ - original_op_id references ingestion_operations.operation_id (soft ref — no FK
18
+ so that dead-lettered rows survive if the source row is later cleaned up).
19
+ - profile_id allows per-profile DLQ dashboards.
20
+ - dead_lettered_at defaults to the current epoch for point-in-time auditing.
21
+ - No TTL/expiry here — retention policy belongs to a future maintenance sweep.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import sqlite3
27
+
28
+ NAME = "M031_dead_letter_operations"
29
+ DB_TARGET = "memory"
30
+
31
+ DDL = """
32
+ CREATE TABLE IF NOT EXISTS dead_letter_operations (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ original_op_id TEXT NOT NULL,
35
+ operation_type TEXT NOT NULL DEFAULT 'M018',
36
+ content TEXT,
37
+ metadata_json TEXT,
38
+ error TEXT,
39
+ attempt_count INTEGER,
40
+ first_attempt_at REAL,
41
+ dead_lettered_at REAL NOT NULL DEFAULT (unixepoch('now')),
42
+ profile_id TEXT
43
+ );
44
+ CREATE INDEX IF NOT EXISTS idx_dlq_profile
45
+ ON dead_letter_operations (profile_id);
46
+ CREATE INDEX IF NOT EXISTS idx_dlq_op_id
47
+ ON dead_letter_operations (original_op_id);
48
+ """
49
+
50
+
51
+ def apply(conn: sqlite3.Connection) -> None:
52
+ """Create the dead_letter_operations table and indexes idempotently."""
53
+ conn.executescript(DDL)
54
+
55
+
56
+ def verify(conn: sqlite3.Connection) -> bool:
57
+ """Return True only when the complete M031 contract is present."""
58
+ table = conn.execute(
59
+ "SELECT 1 FROM sqlite_master WHERE type='table' "
60
+ "AND name='dead_letter_operations'"
61
+ ).fetchone()
62
+ if table is None:
63
+ return False
64
+ columns = {
65
+ row[1]
66
+ for row in conn.execute(
67
+ "PRAGMA table_info(dead_letter_operations)"
68
+ ).fetchall()
69
+ }
70
+ required = {
71
+ "id",
72
+ "original_op_id",
73
+ "operation_type",
74
+ "content",
75
+ "error",
76
+ "attempt_count",
77
+ "dead_lettered_at",
78
+ "profile_id",
79
+ }
80
+ return required <= columns