superlocalmemory 3.8.3 → 3.8.6

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 (125) hide show
  1. package/CHANGELOG.md +76 -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 +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  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/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
  from types import ModuleType
19
19
  from typing import Any, Generator
20
20
 
21
+ from superlocalmemory.storage.write_lock import get_write_lock
21
22
  from superlocalmemory.storage.models import (
22
23
  AtomicFact, CanonicalEntity, ConsolidationAction, ConsolidationActionType,
23
24
  EdgeType, EntityAlias, EntityProfile, FactType, GraphEdge,
@@ -140,7 +141,17 @@ class DatabaseManager:
140
141
  def __init__(self, db_path: str | Path) -> None:
141
142
  self.db_path = Path(db_path)
142
143
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
143
- self._lock = threading.Lock()
144
+ # Shared write-serialisation lock for this db_path.
145
+ # get_write_lock() returns the SAME RLock for every caller that
146
+ # passes the same resolved path, so DatabaseManager, VectorStore,
147
+ # adapter_base, consolidation, and all other in-process writers
148
+ # all share ONE lock → zero cross-connection SQLite WAL contention.
149
+ #
150
+ # RLock (re-entrant) is required: the self-heal backfill pattern
151
+ # with db._lock: # acquires write lock (count: 1→2)
152
+ # vs.upsert(...) # re-acquires same lock (count: 2→3)
153
+ # is safe because the same thread re-enters the RLock.
154
+ self._lock = get_write_lock(self.db_path)
144
155
  # Transaction connections are thread-affine in sqlite3. A manager is
145
156
  # shared across HTTP, materializer, and worker threads, so a process-
146
157
  # global connection slot lets another thread accidentally execute on
@@ -155,6 +166,20 @@ class DatabaseManager:
155
166
  conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") # FIRST — so WAL pragma below uses configured timeout
156
167
  conn.execute("PRAGMA journal_mode=WAL")
157
168
  conn.execute("PRAGMA foreign_keys=ON")
169
+ # Fix D: synchronous=NORMAL is safe under WAL — the WAL ensures
170
+ # atomicity independently of the fsync level. Removing the
171
+ # fsync-on-every-commit penalty halves write latency under the
172
+ # 14 000+ edge-write bursts the materialiser produces per pass.
173
+ # Trade-off: on a power loss between COMMIT and WAL-checkpoint
174
+ # the last committed write to memory.db may be lost. Acceptable
175
+ # for an LLM memory system; NOT acceptable for financial records.
176
+ conn.execute("PRAGMA synchronous=NORMAL")
177
+ # Fix D: reduce WAL auto-checkpoint from the default 1000 to 400
178
+ # frames. Smaller checkpoints run more frequently and complete
179
+ # faster, preventing the WAL file growing unboundedly during
180
+ # high-ingestion bursts (which triggered checkpoint-starvation
181
+ # amplifying the lock storm).
182
+ conn.execute("PRAGMA wal_autocheckpoint=400")
158
183
  conn.commit()
159
184
  finally:
160
185
  conn.close()
@@ -193,9 +218,62 @@ class DatabaseManager:
193
218
  conn.execute("PRAGMA foreign_keys=ON")
194
219
  return conn
195
220
 
221
+ @contextmanager
222
+ def _bind_coordinator_connection(
223
+ self,
224
+ conn: sqlite3.Connection,
225
+ capability: Any,
226
+ ) -> Generator[None, None, None]:
227
+ """Reuse the coordinator's sole writable connection for one handler.
228
+
229
+ This deliberately stays internal: only ``WriteCoordinator`` can issue
230
+ a capability, and that capability is valid only for its worker thread
231
+ and the exact resolved database path. While bound, ``transaction``
232
+ and ``raw_connection`` become no-op ownership scopes: they may yield
233
+ the connection, but they must never commit, rollback, or close it.
234
+ The coordinator owns the enclosing ``BEGIN IMMEDIATE`` and final
235
+ commit/rollback together with the command receipt.
236
+ """
237
+ from superlocalmemory.storage.write_coordinator import WriteCoordinatorError
238
+
239
+ if not isinstance(conn, sqlite3.Connection):
240
+ raise WriteCoordinatorError("coordinator binding requires a sqlite3 connection")
241
+ validate = getattr(capability, "_validate", None)
242
+ if not callable(validate):
243
+ raise WriteCoordinatorError("untrusted coordinator capability")
244
+ try:
245
+ validate(self.db_path.expanduser().resolve())
246
+ except Exception as exc:
247
+ # Import lazily so the storage manager retains its legacy import
248
+ # surface when the coordinator is not used.
249
+ if isinstance(exc, WriteCoordinatorError):
250
+ raise
251
+ raise WriteCoordinatorError("untrusted coordinator capability") from exc
252
+
253
+ attached = conn.execute("PRAGMA database_list").fetchall()
254
+ main_path = next((row[2] for row in attached if row[1] == "main"), "")
255
+ expected_path = self.db_path.expanduser().resolve()
256
+ if not main_path or Path(main_path).expanduser().resolve() != expected_path:
257
+ raise WriteCoordinatorError("coordinator connection targets a different database")
258
+ if getattr(self._txn_state, "conn", None) is not None:
259
+ raise WriteCoordinatorError("database manager is already bound to a transaction")
260
+
261
+ self._txn_state.conn = conn
262
+ self._txn_state.coordinator_bound = True
263
+ try:
264
+ yield
265
+ finally:
266
+ self._txn_state.conn = None
267
+ self._txn_state.coordinator_bound = False
268
+
196
269
  @contextmanager
197
270
  def transaction(self) -> Generator[None, None, None]:
198
271
  """Atomic transaction. All writes commit or rollback together."""
272
+ if getattr(self._txn_state, "coordinator_bound", False):
273
+ # The coordinator has already issued BEGIN IMMEDIATE. Do not
274
+ # create a nested transaction or steal its commit/close lifecycle.
275
+ yield
276
+ return
199
277
  with self._lock:
200
278
  conn = self._connect()
201
279
  self._txn_state.conn = conn
@@ -218,6 +296,12 @@ class DatabaseManager:
218
296
  error, and always closes — mirroring transaction(). This is the public
219
297
  way to obtain a connection; there is no `.conn` attribute.
220
298
  """
299
+ coordinator_conn = getattr(self._txn_state, "conn", None)
300
+ if getattr(self._txn_state, "coordinator_bound", False):
301
+ if coordinator_conn is None: # pragma: no cover - binding invariant
302
+ raise RuntimeError("coordinator binding has no active connection")
303
+ yield coordinator_conn
304
+ return
221
305
  with self._lock:
222
306
  conn = self._connect()
223
307
  self._txn_state.conn = conn
@@ -231,15 +315,21 @@ class DatabaseManager:
231
315
  self._txn_state.conn = None
232
316
  conn.close()
233
317
 
234
- def execute(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
235
- """Execute SQL with automatic retry on SQLITE_BUSY.
318
+ # DML prefixes that require the single-writer lock when executed outside
319
+ # a transaction() context. Checked case-insensitively against the first
320
+ # word of the stripped SQL statement.
321
+ _DML_PREFIXES: frozenset[str] = frozenset({
322
+ "INSERT", "UPDATE", "DELETE", "REPLACE", "UPSERT",
323
+ "CREATE", "DROP", "ALTER",
324
+ })
236
325
 
237
- Uses shared conn inside transaction, else per-call with retry.
238
- """
239
- transaction_conn = getattr(self._txn_state, "conn", None)
240
- if transaction_conn is not None:
241
- return transaction_conn.execute(sql, params).fetchall()
326
+ def _execute_one(self, sql: str, params: tuple[Any, ...]) -> list[sqlite3.Row]:
327
+ """Open a per-call connection, execute, commit, close — with retry.
242
328
 
329
+ Never called when inside a transaction() context (that path uses the
330
+ context's existing connection directly). Factored out of execute() so
331
+ the RLock acquisition logic stays in one place.
332
+ """
243
333
  last_error: Exception | None = None
244
334
  for attempt in range(_MAX_RETRIES):
245
335
  conn = self._connect()
@@ -264,6 +354,42 @@ class DatabaseManager:
264
354
  logger.warning("DB operation failed after %d retries: %s", _MAX_RETRIES, last_error)
265
355
  raise last_error # type: ignore[misc]
266
356
 
357
+ def execute(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
358
+ """Execute SQL with automatic retry on SQLITE_BUSY.
359
+
360
+ Fix B — single-writer serialisation:
361
+ • Inside a transaction(): uses the existing connection directly (no
362
+ lock acquisition — the transaction() context manager already holds
363
+ _lock for the duration of the whole transaction).
364
+ • Outside a transaction(), for DML (INSERT/UPDATE/DELETE/…): acquires
365
+ _lock before opening a per-call connection. This ensures that
366
+ concurrent callers — including background workers that bypass
367
+ transaction() entirely — do not race at the SQLite WAL layer.
368
+ • Outside a transaction(), for SELECTs: no lock needed — WAL mode
369
+ allows concurrent readers without stalling writers.
370
+
371
+ ORDERING INVARIANT: the _txn_state.conn check MUST come before the
372
+ lock acquisition. Reversing the order would deadlock threads that
373
+ call execute() from inside transaction() because threading.RLock is
374
+ re-entrant per-thread but a re-entering thread inside transaction()
375
+ would still try to re-acquire here (lock is already held by the same
376
+ thread, so RLock re-enters safely — but the old threading.Lock would
377
+ have deadlocked; that is exactly why we changed to RLock).
378
+ """
379
+ # Fast path: already inside a transaction — use its connection directly.
380
+ transaction_conn = getattr(self._txn_state, "conn", None)
381
+ if transaction_conn is not None:
382
+ return transaction_conn.execute(sql, params).fetchall()
383
+
384
+ # Determine if this is a write operation that needs serialisation.
385
+ first_word = sql.strip().upper().split(None, 1)[0] if sql.strip() else ""
386
+ if first_word in self._DML_PREFIXES:
387
+ with self._lock:
388
+ return self._execute_one(sql, params)
389
+ else:
390
+ # Read-only path: concurrent reads are safe in WAL mode.
391
+ return self._execute_one(sql, params)
392
+
267
393
  def store_memory(self, record: MemoryRecord) -> str:
268
394
  """Persist a raw memory record. Returns memory_id."""
269
395
  _scope = getattr(record, 'scope', None) or 'personal'
@@ -1306,17 +1432,40 @@ class DatabaseManager:
1306
1432
  )
1307
1433
  return [dict(r) for r in rows]
1308
1434
 
1309
- def cleanup_activation_cache(self) -> int:
1310
- """Delete expired cache entries. Returns count deleted."""
1311
- before = self.execute(
1312
- "SELECT COUNT(*) AS c FROM activation_cache "
1313
- "WHERE expires_at < datetime('now')"
1314
- )
1315
- count = int(before[0]["c"]) if before else 0
1316
- self.execute(
1317
- "DELETE FROM activation_cache WHERE expires_at < datetime('now')"
1318
- )
1319
- return count
1435
+ def cleanup_activation_cache(
1436
+ self, batch_size: int = 5000, max_batches: int = 500,
1437
+ ) -> int:
1438
+ """Delete expired activation_cache rows in bounded batches.
1439
+
1440
+ Wired into MaintenanceScheduler. Historically NEITHER cleanup path was
1441
+ ever called, so activation_cache grew without bound — observed 83,518
1442
+ rows on a real DB, all expired, oldest ~3.5 months old. That bloats the
1443
+ table and its ``idx_actcache_expires`` index and slows every cache
1444
+ INSERT OR REPLACE / lookup.
1445
+
1446
+ Batched so clearing a large backlog never holds the write lock for one
1447
+ long DELETE: each batch commits and yields, letting remember/materialize
1448
+ writers interleave. ``idx_actcache_expires`` makes the predicate
1449
+ index-backed. Steady state (30-min cycle) deletes only one cycle's
1450
+ worth, so the loop exits after a single small batch.
1451
+ """
1452
+ total_deleted = 0
1453
+ for _ in range(max_batches):
1454
+ remaining = self.execute(
1455
+ "SELECT COUNT(*) AS c FROM activation_cache "
1456
+ "WHERE expires_at < datetime('now')"
1457
+ )
1458
+ n = int(remaining[0]["c"]) if remaining else 0
1459
+ if n <= 0:
1460
+ break
1461
+ self.execute(
1462
+ "DELETE FROM activation_cache WHERE cache_id IN ("
1463
+ " SELECT cache_id FROM activation_cache "
1464
+ " WHERE expires_at < datetime('now') LIMIT ?)",
1465
+ (batch_size,),
1466
+ )
1467
+ total_deleted += min(n, batch_size)
1468
+ return total_deleted
1320
1469
 
1321
1470
  def store_fact_importance(self, entry: dict) -> None:
1322
1471
  """Persist fact importance scores."""
@@ -0,0 +1,209 @@
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 atexit
25
+ import queue
26
+ import threading
27
+
28
+ _FLUSH_INTERVAL_S = 2.0
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # General best-effort background writer (seed of the single-writer queue).
33
+ # For NON-ESSENTIAL bookkeeping writes that must never block a read/recall
34
+ # path (access logging, activation-cache warming, etc.). Fire-and-forget:
35
+ # jobs are dropped under extreme backpressure rather than blocking a caller.
36
+ # Substantive/durable writes (remember, materialize) do NOT use this — they
37
+ # get the durable single-writer queue (see WRITE-QUEUE-PLAN.md Stage 3).
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _BG_MAXSIZE = 20000
41
+ _bg_queue: "queue.Queue" = queue.Queue(maxsize=_BG_MAXSIZE)
42
+ _bg_started = False
43
+ _bg_start_lock = threading.Lock()
44
+ _bg_stop = threading.Event()
45
+ _bg_thread: threading.Thread | None = None
46
+
47
+
48
+ def _bg_run(work_queue: "queue.Queue", stop: threading.Event) -> None:
49
+ while not stop.is_set():
50
+ try:
51
+ fn = work_queue.get(timeout=0.1)
52
+ except queue.Empty:
53
+ continue
54
+ try:
55
+ fn()
56
+ except Exception:
57
+ # Best-effort: bookkeeping must never crash the writer thread.
58
+ pass
59
+ finally:
60
+ _bg_queue.task_done()
61
+
62
+
63
+ def _ensure_bg_thread() -> None:
64
+ global _bg_queue, _bg_started, _bg_stop, _bg_thread
65
+ if _bg_thread is not None and _bg_thread.is_alive():
66
+ return
67
+ with _bg_start_lock:
68
+ if _bg_thread is not None and _bg_thread.is_alive():
69
+ return
70
+ _bg_queue = queue.Queue(maxsize=_BG_MAXSIZE)
71
+ _bg_stop = threading.Event()
72
+ _bg_thread = threading.Thread(
73
+ target=_bg_run,
74
+ args=(_bg_queue, _bg_stop),
75
+ name="slm-bg-writer",
76
+ daemon=True,
77
+ )
78
+ _bg_thread.start()
79
+ _bg_started = True
80
+
81
+
82
+ def submit_background(fn) -> None:
83
+ """Run *fn* on the shared background writer. Fire-and-forget, best-effort.
84
+
85
+ Recall/read paths call this instead of writing inline, so they never wait
86
+ on the write lock. Under extreme backpressure the job is dropped (the
87
+ write was non-essential bookkeeping).
88
+ """
89
+ _ensure_bg_thread()
90
+ try:
91
+ _bg_queue.put_nowait(fn)
92
+ except queue.Full:
93
+ pass
94
+
95
+
96
+ def _shutdown_background_writer(timeout: float) -> None:
97
+ """Stop the shared bookkeeping worker and wait for its thread to exit."""
98
+ global _bg_started, _bg_thread
99
+ with _bg_start_lock:
100
+ thread = _bg_thread
101
+ if thread is None:
102
+ return
103
+ _bg_stop.set()
104
+ if thread is not threading.current_thread():
105
+ thread.join(timeout=timeout)
106
+ if not thread.is_alive():
107
+ with _bg_start_lock:
108
+ if _bg_thread is thread:
109
+ _bg_thread = None
110
+ _bg_started = False
111
+
112
+
113
+ class DeferredLastSeen:
114
+ """Coalescing background flusher for canonical_entities.last_seen."""
115
+
116
+ def __init__(self, db, interval_s: float = _FLUSH_INTERVAL_S) -> None:
117
+ self._db = db
118
+ self._interval = interval_s
119
+ self._pending: dict[tuple[str, str], str] = {}
120
+ self._lock = threading.Lock()
121
+ self._stop = threading.Event()
122
+ self._stopped = False
123
+ self._thread = threading.Thread(
124
+ target=self._run, name="slm-deferred-lastseen", daemon=True
125
+ )
126
+ self._thread.start()
127
+
128
+ def touch(self, entity_id: str, profile_id: str, ts: str) -> None:
129
+ """Record a last_seen update. Instant, coalesced, never blocks."""
130
+ with self._lock:
131
+ self._pending[(entity_id, profile_id)] = ts
132
+
133
+ def flush(self) -> int:
134
+ """Write all pending updates now. Returns count. Best-effort."""
135
+ with self._lock:
136
+ if not self._pending:
137
+ return 0
138
+ batch = self._pending
139
+ self._pending = {}
140
+ try:
141
+ # One transaction => one write-lock acquisition for the whole
142
+ # batch, off the recall/ingest thread.
143
+ with self._db.transaction():
144
+ for (entity_id, profile_id), ts in batch.items():
145
+ self._db.execute(
146
+ "UPDATE canonical_entities SET last_seen = ? "
147
+ "WHERE entity_id = ? AND profile_id = ?",
148
+ (ts, entity_id, profile_id),
149
+ )
150
+ return len(batch)
151
+ except Exception:
152
+ # Best-effort: drop this batch rather than raise into a caller.
153
+ return 0
154
+
155
+ def _run(self) -> None:
156
+ while not self._stop.wait(self._interval):
157
+ self.flush()
158
+
159
+ @property
160
+ def is_stopped(self) -> bool:
161
+ return self._stopped
162
+
163
+ def stop(self, timeout: float = 3.0) -> None:
164
+ """Flush then join the owner thread so it cannot outlive its database."""
165
+ self._stop.set()
166
+ self.flush()
167
+ if self._thread is not threading.current_thread():
168
+ self._thread.join(timeout=timeout)
169
+ self._stopped = not self._thread.is_alive()
170
+
171
+
172
+ _registry: dict[int, DeferredLastSeen] = {}
173
+ _registry_lock = threading.Lock()
174
+
175
+
176
+ def get_deferred_last_seen(db) -> DeferredLastSeen:
177
+ """Return the process-wide DeferredLastSeen flusher for *db* (lazy singleton)."""
178
+ key = id(db)
179
+ with _registry_lock:
180
+ writer = _registry.get(key)
181
+ if writer is None or writer.is_stopped:
182
+ writer = DeferredLastSeen(db)
183
+ _registry[key] = writer
184
+ return writer
185
+
186
+
187
+ def shutdown_deferred_writes(timeout: float = 3.0) -> None:
188
+ """Stop every deferred-write worker owned by this process.
189
+
190
+ This is intentionally idempotent: service shutdown and pytest cleanup can
191
+ both call it, and a later caller can lazily create fresh workers.
192
+ """
193
+ with _registry_lock:
194
+ writers = list(_registry.values())
195
+ _registry.clear()
196
+ for writer in writers:
197
+ writer.stop(timeout=timeout)
198
+ _shutdown_background_writer(timeout)
199
+
200
+
201
+ atexit.register(shutdown_deferred_writes)
202
+
203
+
204
+ __all__ = [
205
+ "DeferredLastSeen",
206
+ "get_deferred_last_seen",
207
+ "shutdown_deferred_writes",
208
+ "submit_background",
209
+ ]
@@ -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,115 @@
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. The helper
44
+ opens SQLite with ``mode=ro`` and ``PRAGMA query_only=ON``; it uses the
45
+ 3.8.6 read-path budget of at most 250ms during a brief checkpoint window.
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import os
50
+ import sqlite3
51
+ from contextlib import contextmanager
52
+ from pathlib import Path
53
+ from typing import Generator
54
+
55
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
56
+ from superlocalmemory.storage.write_lock import get_write_lock
57
+
58
+
59
+ def _busy_timeout_ms() -> int:
60
+ """Busy-timeout in ms, env-overridable, matching DatabaseManager's default.
61
+
62
+ Kept in sync with ``storage/database.py::_BUSY_TIMEOUT_MS`` (env
63
+ ``SLM_DB_BUSY_TIMEOUT_MS``, default 10_000) so every memory.db connection
64
+ in the process — and in hook/CLI child processes that import this — waits
65
+ the same amount for the single writer.
66
+ """
67
+ try:
68
+ return max(0, int(os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
69
+ except (TypeError, ValueError):
70
+ return 10000
71
+
72
+
73
+ @contextmanager
74
+ def memory_write(db_path: str | Path) -> Generator[sqlite3.Connection, None, None]:
75
+ """Yield a serialised, busy-timeout-guarded WRITE connection to memory.db.
76
+
77
+ Acquires the process write lock (outermost), opens a short-lived
78
+ connection with ``busy_timeout``, commits on success / rolls back on
79
+ error, and always closes.
80
+
81
+ HARD RULE: keep the ``with`` block short — never embed / call the network /
82
+ run an unbounded transaction while holding it.
83
+ """
84
+ ms = _busy_timeout_ms()
85
+ lock = get_write_lock(db_path)
86
+ with lock:
87
+ conn = sqlite3.connect(str(db_path), timeout=ms / 1000.0)
88
+ try:
89
+ conn.execute(f"PRAGMA busy_timeout={ms}")
90
+ conn.row_factory = sqlite3.Row
91
+ yield conn
92
+ conn.commit()
93
+ except Exception:
94
+ try:
95
+ conn.rollback()
96
+ except Exception:
97
+ pass
98
+ raise
99
+ finally:
100
+ conn.close()
101
+
102
+
103
+ @contextmanager
104
+ def memory_read(db_path: str | Path) -> Generator[sqlite3.Connection, None, None]:
105
+ """Yield a physically read-only connection with no write lock.
106
+
107
+ WAL permits concurrent readers, so this deliberately does NOT take the
108
+ write lock. A 250ms bounded wait protects the query path from a brief
109
+ checkpoint without converting a dashboard recall into a long hang.
110
+ """
111
+ with ReadConnectionFactory(db_path, timeout_ms=min(_busy_timeout_ms(), 250)).snapshot() as conn:
112
+ yield conn
113
+
114
+
115
+ __all__ = ["memory_write", "memory_read"]