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.
- package/CHANGELOG.md +76 -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 +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- 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/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- 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 +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- 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/graph/cozo_backend.py +5 -5
- 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/bandit.py +50 -1
- 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/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- 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 +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -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/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -122,6 +122,12 @@ 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
|
+
)
|
|
128
|
+
from superlocalmemory.storage.migrations import (
|
|
129
|
+
M032_write_coordinator_admission as _M032,
|
|
130
|
+
)
|
|
125
131
|
|
|
126
132
|
# Map migration name → module (used for the optional ``verify(conn)`` hook
|
|
127
133
|
# that lets the runner detect "already applied" state when an idempotent
|
|
@@ -156,6 +162,8 @@ _MODULES = {
|
|
|
156
162
|
_M028.NAME: _M028,
|
|
157
163
|
_M029.NAME: _M029,
|
|
158
164
|
_M030.NAME: _M030,
|
|
165
|
+
_M031.NAME: _M031,
|
|
166
|
+
_M032.NAME: _M032,
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
logger = logging.getLogger(__name__)
|
|
@@ -169,6 +177,12 @@ _KNOWN_EQUIVALENT_DDL_HASHES: dict[str, frozenset[str]] = {
|
|
|
169
177
|
# v3.4.22 model_version-default variant shipped through 3.6.x.
|
|
170
178
|
"d28666fa1dfa66e6514efd288e6748363513da2255a4cee95d80f233e6728ae7",
|
|
171
179
|
}),
|
|
180
|
+
_M032.NAME: frozenset({
|
|
181
|
+
# Provisional 3.8.6 development ledger: global idempotency_key and
|
|
182
|
+
# operation_id uniqueness. Its standalone table is safely rebuilt by
|
|
183
|
+
# M032.repair() into the profile-scoped receipt contract.
|
|
184
|
+
"e45df41becba3d0c3342eca5ec3bd83aa899eef76943c819d2da73b4ca1625a7",
|
|
185
|
+
}),
|
|
172
186
|
}
|
|
173
187
|
|
|
174
188
|
|
|
@@ -216,6 +230,12 @@ MIGRATIONS: list[Migration] = [
|
|
|
216
230
|
Migration(name=_M024.NAME, db_target="memory", ddl=_M024.DDL),
|
|
217
231
|
Migration(name=_M019.NAME, db_target="memory", ddl=_M019.DDL,
|
|
218
232
|
dependencies=(_M018.NAME,)),
|
|
233
|
+
# M031 creates dead_letter_operations — standalone table, no FK to engine-
|
|
234
|
+
# bootstrapped tables, so it can run during apply_all (before engine init).
|
|
235
|
+
Migration(name=_M031.NAME, db_target="memory", ddl=_M031.DDL),
|
|
236
|
+
# M032 is standalone and must precede daemon readiness: typed writes use
|
|
237
|
+
# this append-only receipt ledger for durable idempotency.
|
|
238
|
+
Migration(name=_M032.NAME, db_target="memory", ddl=_M032.DDL),
|
|
219
239
|
# M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
|
|
220
240
|
]
|
|
221
241
|
|
|
@@ -415,6 +435,30 @@ def _apply_single(
|
|
|
415
435
|
)
|
|
416
436
|
except sqlite3.Error: # pragma: no cover
|
|
417
437
|
pass
|
|
438
|
+
if dry_run:
|
|
439
|
+
return (
|
|
440
|
+
"skipped",
|
|
441
|
+
"dry-run: would repair allowlisted historical schema",
|
|
442
|
+
)
|
|
443
|
+
repair_fn = getattr(mod, "repair", None) if mod is not None else None
|
|
444
|
+
if callable(repair_fn):
|
|
445
|
+
try:
|
|
446
|
+
repair_fn(conn)
|
|
447
|
+
if not bool(verify_fn(conn)):
|
|
448
|
+
return (
|
|
449
|
+
"failed",
|
|
450
|
+
f"safe repair did not restore {migration.name}",
|
|
451
|
+
)
|
|
452
|
+
_upsert_log(conn, migration.name, ddl_hash, "complete")
|
|
453
|
+
return (
|
|
454
|
+
"applied",
|
|
455
|
+
"allowlisted historical schema repaired safely",
|
|
456
|
+
)
|
|
457
|
+
except sqlite3.Error as exc:
|
|
458
|
+
return (
|
|
459
|
+
"failed",
|
|
460
|
+
f"safe repair failed for {migration.name}: {exc}",
|
|
461
|
+
)
|
|
418
462
|
detail = (
|
|
419
463
|
f"DDL drift detected for {migration.name}: "
|
|
420
464
|
f"logged={logged_hash[:8]}... current={ddl_hash[:8]}..."
|
|
@@ -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
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""M032 — durable, append-only receipts for canonical write commands.
|
|
5
|
+
|
|
6
|
+
The receipt ledger is profile-isolated at the client idempotency boundary.
|
|
7
|
+
``command_id`` and ``journal_id`` remain globally durable identifiers; a
|
|
8
|
+
client-supplied idempotency key is unique only with its target profile. An
|
|
9
|
+
operation id is a projection label (for example, ``update:<fact_id>``), not a
|
|
10
|
+
durable command identifier, so it is deliberately indexed but not unique.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sqlite3
|
|
16
|
+
|
|
17
|
+
NAME = "M032_write_coordinator_admission"
|
|
18
|
+
DB_TARGET = "memory"
|
|
19
|
+
|
|
20
|
+
DDL = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS write_commits (
|
|
22
|
+
commit_sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
command_id TEXT NOT NULL UNIQUE,
|
|
24
|
+
journal_id TEXT NOT NULL UNIQUE,
|
|
25
|
+
command_kind TEXT NOT NULL,
|
|
26
|
+
request_hash TEXT NOT NULL,
|
|
27
|
+
profile_id TEXT NOT NULL,
|
|
28
|
+
idempotency_key TEXT NOT NULL,
|
|
29
|
+
operation_id TEXT NOT NULL,
|
|
30
|
+
receipt_json TEXT NOT NULL,
|
|
31
|
+
committed_at REAL NOT NULL,
|
|
32
|
+
UNIQUE(profile_id, idempotency_key)
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_write_commits_committed_at
|
|
35
|
+
ON write_commits (committed_at);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_write_commits_operation_id
|
|
37
|
+
ON write_commits (operation_id);
|
|
38
|
+
CREATE TRIGGER IF NOT EXISTS trg_write_commits_immutable_update
|
|
39
|
+
BEFORE UPDATE ON write_commits
|
|
40
|
+
BEGIN
|
|
41
|
+
SELECT RAISE(ABORT, 'write_commits receipts are immutable');
|
|
42
|
+
END;
|
|
43
|
+
CREATE TRIGGER IF NOT EXISTS trg_write_commits_immutable_delete
|
|
44
|
+
BEFORE DELETE ON write_commits
|
|
45
|
+
BEGIN
|
|
46
|
+
SELECT RAISE(ABORT, 'write_commits receipts are immutable');
|
|
47
|
+
END;
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
_CREATE_TABLE = DDL.split(";", 1)[0]
|
|
51
|
+
_CREATE_COMMITTED_AT_INDEX = (
|
|
52
|
+
"CREATE INDEX IF NOT EXISTS idx_write_commits_committed_at "
|
|
53
|
+
"ON write_commits (committed_at)"
|
|
54
|
+
)
|
|
55
|
+
_CREATE_OPERATION_ID_INDEX = (
|
|
56
|
+
"CREATE INDEX IF NOT EXISTS idx_write_commits_operation_id "
|
|
57
|
+
"ON write_commits (operation_id)"
|
|
58
|
+
)
|
|
59
|
+
_CREATE_UPDATE_TRIGGER = """
|
|
60
|
+
CREATE TRIGGER IF NOT EXISTS trg_write_commits_immutable_update
|
|
61
|
+
BEFORE UPDATE ON write_commits
|
|
62
|
+
BEGIN
|
|
63
|
+
SELECT RAISE(ABORT, 'write_commits receipts are immutable');
|
|
64
|
+
END
|
|
65
|
+
"""
|
|
66
|
+
_CREATE_DELETE_TRIGGER = """
|
|
67
|
+
CREATE TRIGGER IF NOT EXISTS trg_write_commits_immutable_delete
|
|
68
|
+
BEFORE DELETE ON write_commits
|
|
69
|
+
BEGIN
|
|
70
|
+
SELECT RAISE(ABORT, 'write_commits receipts are immutable');
|
|
71
|
+
END
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def apply(conn: sqlite3.Connection) -> None:
|
|
76
|
+
"""Create or safely upgrade the profile-scoped append-only receipt ledger."""
|
|
77
|
+
if verify(conn):
|
|
78
|
+
return
|
|
79
|
+
if not _table_exists(conn):
|
|
80
|
+
_create_current_schema(conn)
|
|
81
|
+
return
|
|
82
|
+
_rebuild_legacy_schema(conn)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def repair(conn: sqlite3.Connection) -> None:
|
|
86
|
+
"""Repair a completed provisional M032 in developer/test databases."""
|
|
87
|
+
apply(conn)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify(conn: sqlite3.Connection) -> bool:
|
|
91
|
+
"""Return true only when the full profile-safe ledger contract exists."""
|
|
92
|
+
if not _table_exists(conn):
|
|
93
|
+
return False
|
|
94
|
+
columns = {row[1] for row in conn.execute("PRAGMA table_info(write_commits)").fetchall()}
|
|
95
|
+
required = {
|
|
96
|
+
"commit_sequence",
|
|
97
|
+
"command_id",
|
|
98
|
+
"journal_id",
|
|
99
|
+
"command_kind",
|
|
100
|
+
"request_hash",
|
|
101
|
+
"profile_id",
|
|
102
|
+
"idempotency_key",
|
|
103
|
+
"operation_id",
|
|
104
|
+
"receipt_json",
|
|
105
|
+
"committed_at",
|
|
106
|
+
}
|
|
107
|
+
if not required <= columns:
|
|
108
|
+
return False
|
|
109
|
+
if not _has_unique_index(conn, ("command_id",)):
|
|
110
|
+
return False
|
|
111
|
+
if not _has_unique_index(conn, ("journal_id",)):
|
|
112
|
+
return False
|
|
113
|
+
if not _has_unique_index(conn, ("profile_id", "idempotency_key")):
|
|
114
|
+
return False
|
|
115
|
+
if _has_unique_index(conn, ("idempotency_key",)):
|
|
116
|
+
return False
|
|
117
|
+
if _has_unique_index(conn, ("operation_id",)):
|
|
118
|
+
return False
|
|
119
|
+
object_names = {
|
|
120
|
+
row[0]
|
|
121
|
+
for row in conn.execute(
|
|
122
|
+
"SELECT name FROM sqlite_master WHERE name IN (?, ?, ?, ?)",
|
|
123
|
+
(
|
|
124
|
+
"idx_write_commits_committed_at",
|
|
125
|
+
"idx_write_commits_operation_id",
|
|
126
|
+
"trg_write_commits_immutable_update",
|
|
127
|
+
"trg_write_commits_immutable_delete",
|
|
128
|
+
),
|
|
129
|
+
).fetchall()
|
|
130
|
+
}
|
|
131
|
+
return object_names == {
|
|
132
|
+
"idx_write_commits_committed_at",
|
|
133
|
+
"idx_write_commits_operation_id",
|
|
134
|
+
"trg_write_commits_immutable_update",
|
|
135
|
+
"trg_write_commits_immutable_delete",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _table_exists(conn: sqlite3.Connection) -> bool:
|
|
140
|
+
return conn.execute(
|
|
141
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='write_commits'"
|
|
142
|
+
).fetchone() is not None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _has_unique_index(conn: sqlite3.Connection, columns: tuple[str, ...]) -> bool:
|
|
146
|
+
for index in conn.execute("PRAGMA index_list(write_commits)").fetchall():
|
|
147
|
+
if not index[2]:
|
|
148
|
+
continue
|
|
149
|
+
index_columns = tuple(
|
|
150
|
+
row[2] for row in conn.execute(f"PRAGMA index_info({index[1]})").fetchall()
|
|
151
|
+
)
|
|
152
|
+
if index_columns == columns:
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _create_current_schema(conn: sqlite3.Connection) -> None:
|
|
158
|
+
conn.execute(_CREATE_TABLE)
|
|
159
|
+
conn.execute(_CREATE_COMMITTED_AT_INDEX)
|
|
160
|
+
conn.execute(_CREATE_OPERATION_ID_INDEX)
|
|
161
|
+
conn.execute(_CREATE_UPDATE_TRIGGER)
|
|
162
|
+
conn.execute(_CREATE_DELETE_TRIGGER)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _rebuild_legacy_schema(conn: sqlite3.Connection) -> None:
|
|
166
|
+
"""Rebuild only M032's own standalone table under one savepoint."""
|
|
167
|
+
conn.execute("SAVEPOINT m032_profile_scoped_idempotency")
|
|
168
|
+
try:
|
|
169
|
+
conn.execute("DROP TRIGGER IF EXISTS trg_write_commits_immutable_update")
|
|
170
|
+
conn.execute("DROP TRIGGER IF EXISTS trg_write_commits_immutable_delete")
|
|
171
|
+
conn.execute("ALTER TABLE write_commits RENAME TO write_commits_legacy")
|
|
172
|
+
conn.execute("DROP INDEX IF EXISTS idx_write_commits_committed_at")
|
|
173
|
+
conn.execute("DROP INDEX IF EXISTS idx_write_commits_operation_id")
|
|
174
|
+
_create_current_schema(conn)
|
|
175
|
+
conn.execute(
|
|
176
|
+
"INSERT INTO write_commits("
|
|
177
|
+
"commit_sequence, command_id, journal_id, command_kind, request_hash, "
|
|
178
|
+
"profile_id, idempotency_key, operation_id, receipt_json, committed_at"
|
|
179
|
+
") SELECT commit_sequence, command_id, journal_id, command_kind, request_hash, "
|
|
180
|
+
"profile_id, idempotency_key, operation_id, receipt_json, committed_at "
|
|
181
|
+
"FROM write_commits_legacy"
|
|
182
|
+
)
|
|
183
|
+
conn.execute("DROP TABLE write_commits_legacy")
|
|
184
|
+
except BaseException:
|
|
185
|
+
conn.execute("ROLLBACK TO m032_profile_scoped_idempotency")
|
|
186
|
+
conn.execute("RELEASE m032_profile_scoped_idempotency")
|
|
187
|
+
raise
|
|
188
|
+
conn.execute("RELEASE m032_profile_scoped_idempotency")
|