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.
- package/CHANGELOG.md +42 -0
- package/README.md +3 -2
- 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/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +67 -68
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +200 -31
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- 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
|
|
105
|
-
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
187
|
-
"state='
|
|
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
|
-
(
|
|
195
|
+
(_now(),),
|
|
191
196
|
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
"
|
|
223
|
-
|
|
224
|
-
"
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|