superlocalmemory 3.8.2 → 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 +57 -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 +139 -91
- 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 +283 -39
- 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
- package/src/superlocalmemory/ui/js/core.js +6 -1
|
@@ -18,6 +18,8 @@ from typing import Any
|
|
|
18
18
|
|
|
19
19
|
from fastapi import HTTPException, Request
|
|
20
20
|
|
|
21
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
22
|
+
|
|
21
23
|
|
|
22
24
|
# H-04 (3.7.9): the test-client auth bypass must be impossible in a real daemon
|
|
23
25
|
# even if SLM_TEST_ISOLATION=1 leaks into a production environment. Anchor it to
|
|
@@ -89,7 +91,7 @@ def require_write_actor(
|
|
|
89
91
|
# missing peer address (e.g. behind a proxy that strips it) must not be
|
|
90
92
|
# trusted just because an install token is presented. require_http_mutation_actor
|
|
91
93
|
# already excludes "" from its loopback set; keep the two paths consistent.
|
|
92
|
-
if is_test_client or client_host
|
|
94
|
+
if is_test_client or _is_loopback_host(client_host):
|
|
93
95
|
from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
|
|
94
96
|
|
|
95
97
|
return local_trusted_actor_id(actor_kind)
|
|
@@ -103,7 +105,15 @@ def require_write_actor(
|
|
|
103
105
|
).hexdigest()
|
|
104
106
|
return f"api-key:{actor_kind}:{fingerprint}"
|
|
105
107
|
|
|
106
|
-
raise HTTPException(
|
|
108
|
+
raise HTTPException(
|
|
109
|
+
403,
|
|
110
|
+
detail=(
|
|
111
|
+
"Write rejected: this origin requires an API key (X-SLM-API-Key). "
|
|
112
|
+
"The install token (X-Install-Token) is accepted only from loopback "
|
|
113
|
+
"(127.x.x.x / ::1 / localhost). If calling from a container or "
|
|
114
|
+
"remote host, configure SLM_API_KEY and present it as X-SLM-API-Key."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
107
117
|
|
|
108
118
|
|
|
109
119
|
def require_http_mutation_actor(
|
|
@@ -134,7 +144,7 @@ def require_http_mutation_actor(
|
|
|
134
144
|
|
|
135
145
|
client_host = request.client.host if request.client else ""
|
|
136
146
|
is_test_client = client_host == "testclient" and _TEST_ISOLATION_ALLOWED
|
|
137
|
-
loopback = client_host
|
|
147
|
+
loopback = _is_loopback_host(client_host)
|
|
138
148
|
# H-01: the loopback trusted-actor bypass is suppressed when the operator
|
|
139
149
|
# opts into SLM_REQUIRE_CREDENTIALS; in-process tests keep the bypass.
|
|
140
150
|
if is_test_client or (loopback and not _REQUIRE_CREDENTIALS):
|
|
@@ -153,7 +163,15 @@ def require_http_mutation_actor(
|
|
|
153
163
|
).hexdigest()
|
|
154
164
|
return f"mesh-secret:{fingerprint}"
|
|
155
165
|
|
|
156
|
-
raise HTTPException(
|
|
166
|
+
raise HTTPException(
|
|
167
|
+
403,
|
|
168
|
+
detail=(
|
|
169
|
+
"Mutation rejected: present one of X-SLM-Daemon-Capability, "
|
|
170
|
+
"X-Install-Token (loopback only), or X-SLM-API-Key. "
|
|
171
|
+
"Uncredentialed writes are accepted only from loopback. "
|
|
172
|
+
"See SLM_API_KEY in the deployment guide for network access."
|
|
173
|
+
),
|
|
174
|
+
)
|
|
157
175
|
|
|
158
176
|
|
|
159
177
|
def authenticated_request_actor(
|
|
@@ -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
|
-
|
|
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()
|
|
@@ -231,15 +256,21 @@ class DatabaseManager:
|
|
|
231
256
|
self._txn_state.conn = None
|
|
232
257
|
conn.close()
|
|
233
258
|
|
|
234
|
-
|
|
235
|
-
|
|
259
|
+
# DML prefixes that require the single-writer lock when executed outside
|
|
260
|
+
# a transaction() context. Checked case-insensitively against the first
|
|
261
|
+
# word of the stripped SQL statement.
|
|
262
|
+
_DML_PREFIXES: frozenset[str] = frozenset({
|
|
263
|
+
"INSERT", "UPDATE", "DELETE", "REPLACE", "UPSERT",
|
|
264
|
+
"CREATE", "DROP", "ALTER",
|
|
265
|
+
})
|
|
236
266
|
|
|
237
|
-
|
|
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()
|
|
267
|
+
def _execute_one(self, sql: str, params: tuple[Any, ...]) -> list[sqlite3.Row]:
|
|
268
|
+
"""Open a per-call connection, execute, commit, close — with retry.
|
|
242
269
|
|
|
270
|
+
Never called when inside a transaction() context (that path uses the
|
|
271
|
+
context's existing connection directly). Factored out of execute() so
|
|
272
|
+
the RLock acquisition logic stays in one place.
|
|
273
|
+
"""
|
|
243
274
|
last_error: Exception | None = None
|
|
244
275
|
for attempt in range(_MAX_RETRIES):
|
|
245
276
|
conn = self._connect()
|
|
@@ -264,6 +295,42 @@ class DatabaseManager:
|
|
|
264
295
|
logger.warning("DB operation failed after %d retries: %s", _MAX_RETRIES, last_error)
|
|
265
296
|
raise last_error # type: ignore[misc]
|
|
266
297
|
|
|
298
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
|
|
299
|
+
"""Execute SQL with automatic retry on SQLITE_BUSY.
|
|
300
|
+
|
|
301
|
+
Fix B — single-writer serialisation:
|
|
302
|
+
• Inside a transaction(): uses the existing connection directly (no
|
|
303
|
+
lock acquisition — the transaction() context manager already holds
|
|
304
|
+
_lock for the duration of the whole transaction).
|
|
305
|
+
• Outside a transaction(), for DML (INSERT/UPDATE/DELETE/…): acquires
|
|
306
|
+
_lock before opening a per-call connection. This ensures that
|
|
307
|
+
concurrent callers — including background workers that bypass
|
|
308
|
+
transaction() entirely — do not race at the SQLite WAL layer.
|
|
309
|
+
• Outside a transaction(), for SELECTs: no lock needed — WAL mode
|
|
310
|
+
allows concurrent readers without stalling writers.
|
|
311
|
+
|
|
312
|
+
ORDERING INVARIANT: the _txn_state.conn check MUST come before the
|
|
313
|
+
lock acquisition. Reversing the order would deadlock threads that
|
|
314
|
+
call execute() from inside transaction() because threading.RLock is
|
|
315
|
+
re-entrant per-thread but a re-entering thread inside transaction()
|
|
316
|
+
would still try to re-acquire here (lock is already held by the same
|
|
317
|
+
thread, so RLock re-enters safely — but the old threading.Lock would
|
|
318
|
+
have deadlocked; that is exactly why we changed to RLock).
|
|
319
|
+
"""
|
|
320
|
+
# Fast path: already inside a transaction — use its connection directly.
|
|
321
|
+
transaction_conn = getattr(self._txn_state, "conn", None)
|
|
322
|
+
if transaction_conn is not None:
|
|
323
|
+
return transaction_conn.execute(sql, params).fetchall()
|
|
324
|
+
|
|
325
|
+
# Determine if this is a write operation that needs serialisation.
|
|
326
|
+
first_word = sql.strip().upper().split(None, 1)[0] if sql.strip() else ""
|
|
327
|
+
if first_word in self._DML_PREFIXES:
|
|
328
|
+
with self._lock:
|
|
329
|
+
return self._execute_one(sql, params)
|
|
330
|
+
else:
|
|
331
|
+
# Read-only path: concurrent reads are safe in WAL mode.
|
|
332
|
+
return self._execute_one(sql, params)
|
|
333
|
+
|
|
267
334
|
def store_memory(self, record: MemoryRecord) -> str:
|
|
268
335
|
"""Persist a raw memory record. Returns memory_id."""
|
|
269
336
|
_scope = getattr(record, 'scope', None) or 'personal'
|
|
@@ -1306,17 +1373,40 @@ class DatabaseManager:
|
|
|
1306
1373
|
)
|
|
1307
1374
|
return [dict(r) for r in rows]
|
|
1308
1375
|
|
|
1309
|
-
def cleanup_activation_cache(
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1376
|
+
def cleanup_activation_cache(
|
|
1377
|
+
self, batch_size: int = 5000, max_batches: int = 500,
|
|
1378
|
+
) -> int:
|
|
1379
|
+
"""Delete expired activation_cache rows in bounded batches.
|
|
1380
|
+
|
|
1381
|
+
Wired into MaintenanceScheduler. Historically NEITHER cleanup path was
|
|
1382
|
+
ever called, so activation_cache grew without bound — observed 83,518
|
|
1383
|
+
rows on a real DB, all expired, oldest ~3.5 months old. That bloats the
|
|
1384
|
+
table and its ``idx_actcache_expires`` index and slows every cache
|
|
1385
|
+
INSERT OR REPLACE / lookup.
|
|
1386
|
+
|
|
1387
|
+
Batched so clearing a large backlog never holds the write lock for one
|
|
1388
|
+
long DELETE: each batch commits and yields, letting remember/materialize
|
|
1389
|
+
writers interleave. ``idx_actcache_expires`` makes the predicate
|
|
1390
|
+
index-backed. Steady state (30-min cycle) deletes only one cycle's
|
|
1391
|
+
worth, so the loop exits after a single small batch.
|
|
1392
|
+
"""
|
|
1393
|
+
total_deleted = 0
|
|
1394
|
+
for _ in range(max_batches):
|
|
1395
|
+
remaining = self.execute(
|
|
1396
|
+
"SELECT COUNT(*) AS c FROM activation_cache "
|
|
1397
|
+
"WHERE expires_at < datetime('now')"
|
|
1398
|
+
)
|
|
1399
|
+
n = int(remaining[0]["c"]) if remaining else 0
|
|
1400
|
+
if n <= 0:
|
|
1401
|
+
break
|
|
1402
|
+
self.execute(
|
|
1403
|
+
"DELETE FROM activation_cache WHERE cache_id IN ("
|
|
1404
|
+
" SELECT cache_id FROM activation_cache "
|
|
1405
|
+
" WHERE expires_at < datetime('now') LIMIT ?)",
|
|
1406
|
+
(batch_size,),
|
|
1407
|
+
)
|
|
1408
|
+
total_deleted += min(n, batch_size)
|
|
1409
|
+
return total_deleted
|
|
1320
1410
|
|
|
1321
1411
|
def store_fact_importance(self, entry: dict) -> None:
|
|
1322
1412
|
"""Persist fact importance scores."""
|
|
@@ -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
|
|