hermes-plugin-qdrant-memory 0.2.0__py3-none-any.whl
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.
- hermes_plugin_qdrant_memory-0.2.0.dist-info/METADATA +495 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/RECORD +29 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/WHEEL +5 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/entry_points.txt +5 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/top_level.txt +1 -0
- qdrant_memory/__init__.py +33 -0
- qdrant_memory/cli.py +887 -0
- qdrant_memory/config.py +313 -0
- qdrant_memory/config_schema.py +111 -0
- qdrant_memory/dedupe.py +26 -0
- qdrant_memory/embedding.py +114 -0
- qdrant_memory/extraction.py +90 -0
- qdrant_memory/inventory.py +153 -0
- qdrant_memory/ledger.py +686 -0
- qdrant_memory/mcp_server.py +57 -0
- qdrant_memory/migration.py +525 -0
- qdrant_memory/models.py +130 -0
- qdrant_memory/ownership.py +48 -0
- qdrant_memory/plugin.yaml +13 -0
- qdrant_memory/progress.py +89 -0
- qdrant_memory/provider.py +568 -0
- qdrant_memory/qdrant_store.py +332 -0
- qdrant_memory/reset.py +317 -0
- qdrant_memory/retry.py +51 -0
- qdrant_memory/runtime.py +314 -0
- qdrant_memory/standalone.py +203 -0
- qdrant_memory/tools.py +81 -0
- qdrant_memory/version.py +3 -0
qdrant_memory/ledger.py
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
"""Private SQLite write-ahead ledger. Pending payloads survive process death."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from itertools import chain, islice
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from threading import RLock
|
|
11
|
+
|
|
12
|
+
from .models import digest, now, timestamp
|
|
13
|
+
from .retry import safe_error
|
|
14
|
+
|
|
15
|
+
DELETE_FENCE_PREFIX = "hermes-qdrant-delete-fence-v1:"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Ledger:
|
|
19
|
+
"""Persist replayable events, mutations, migration manifests and bounded metrics."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, home, collection="hermes_qdrant_memory"):
|
|
22
|
+
"""Open a private profile ledger with WAL and FULL synchronization."""
|
|
23
|
+
self.collection = collection
|
|
24
|
+
directory = Path(home) / "qdrant-memory"
|
|
25
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
26
|
+
directory.chmod(0o700)
|
|
27
|
+
path = directory / "state.db"
|
|
28
|
+
descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
29
|
+
os.close(descriptor)
|
|
30
|
+
path.chmod(0o600)
|
|
31
|
+
self.lock = RLock()
|
|
32
|
+
self.db = sqlite3.connect(path, check_same_thread=False, timeout=30)
|
|
33
|
+
self.db.row_factory = sqlite3.Row
|
|
34
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
35
|
+
self.db.execute("PRAGMA synchronous=FULL")
|
|
36
|
+
self.db.executescript("""
|
|
37
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
38
|
+
event_id TEXT PRIMARY KEY, session_id TEXT, payload_json TEXT NOT NULL,
|
|
39
|
+
status TEXT NOT NULL DEFAULT 'PENDING', attempts INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
last_error TEXT, created_at TEXT, committed_at TEXT, collection TEXT NOT NULL);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
42
|
+
idempotency_key TEXT PRIMARY KEY, point_id TEXT NOT NULL, action TEXT NOT NULL,
|
|
43
|
+
payload_json TEXT NOT NULL, source_id TEXT, source_version TEXT, content_hash TEXT,
|
|
44
|
+
status TEXT NOT NULL DEFAULT 'PENDING', attempts INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
last_error TEXT, created_at TEXT, committed_at TEXT, collection TEXT NOT NULL);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS events_by_collection_status
|
|
47
|
+
ON events(collection,status);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS operations_by_collection_status
|
|
49
|
+
ON operations(collection,status);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS operations_by_point_status
|
|
51
|
+
ON operations(collection,point_id,status);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS operations_delete_by_point
|
|
53
|
+
ON operations(collection,source_id,action,point_id);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS operations_delete_by_hash
|
|
55
|
+
ON operations(collection,source_id,action,content_hash);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS operations_unscrubbed_by_collection
|
|
57
|
+
ON operations(collection)
|
|
58
|
+
WHERE status IN ('COMMITTED','SUPERSEDED') AND payload_json!='{}';
|
|
59
|
+
CREATE INDEX IF NOT EXISTS operations_unscrubbed_by_point
|
|
60
|
+
ON operations(collection,point_id)
|
|
61
|
+
WHERE status IN ('COMMITTED','SUPERSEDED') AND payload_json!='{}';
|
|
62
|
+
CREATE TABLE IF NOT EXISTS manifests (
|
|
63
|
+
migration_id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, collection TEXT NOT NULL);
|
|
64
|
+
CREATE TABLE IF NOT EXISTS metrics (name TEXT, value REAL, collection TEXT);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS counters (
|
|
66
|
+
name TEXT, value INTEGER, collection TEXT, PRIMARY KEY(name,collection));
|
|
67
|
+
CREATE TABLE IF NOT EXISTS unattributed_sessions (
|
|
68
|
+
session_id TEXT, collection TEXT, PRIMARY KEY(session_id,collection));
|
|
69
|
+
CREATE TABLE IF NOT EXISTS session_provenance (
|
|
70
|
+
session_id TEXT, collection TEXT, authors_json TEXT NOT NULL,
|
|
71
|
+
explicit_author INTEGER NOT NULL, history_known INTEGER NOT NULL,
|
|
72
|
+
PRIMARY KEY(session_id,collection));
|
|
73
|
+
""")
|
|
74
|
+
self._resumable_operation_keys = self._load_resumable_operation_keys()
|
|
75
|
+
with self.lock, self.db:
|
|
76
|
+
self._scrub_terminal_payloads()
|
|
77
|
+
|
|
78
|
+
def clear_destination(self):
|
|
79
|
+
"""Forget only this destination's work so cleared memories cannot replay.
|
|
80
|
+
|
|
81
|
+
Transcript authorship and quarantine remain in place across collection resets.
|
|
82
|
+
"""
|
|
83
|
+
with self.lock, self.db:
|
|
84
|
+
for table in ("events", "operations", "manifests", "metrics", "counters"):
|
|
85
|
+
self.db.execute(f"DELETE FROM {table} WHERE collection=?", (self.collection,))
|
|
86
|
+
self._resumable_operation_keys = set()
|
|
87
|
+
|
|
88
|
+
def unattributed_sessions(self):
|
|
89
|
+
"""Load transcript quarantine for this destination, including resumed sessions."""
|
|
90
|
+
with self.lock:
|
|
91
|
+
return {
|
|
92
|
+
row[0]
|
|
93
|
+
for row in self.db.execute(
|
|
94
|
+
"SELECT session_id FROM unattributed_sessions WHERE collection=?",
|
|
95
|
+
(self.collection,),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def set_session_unattributed(self, session_id, unattributed=True):
|
|
100
|
+
"""Retain uncertain transcript provenance until the host explicitly resets it."""
|
|
101
|
+
with self.lock, self.db:
|
|
102
|
+
if unattributed:
|
|
103
|
+
self.db.execute(
|
|
104
|
+
"INSERT OR IGNORE INTO unattributed_sessions VALUES(?,?)",
|
|
105
|
+
(session_id, self.collection),
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
self.db.execute(
|
|
109
|
+
"DELETE FROM unattributed_sessions WHERE session_id=? AND collection=?",
|
|
110
|
+
(session_id, self.collection),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def session_provenance(self, session_id=None):
|
|
114
|
+
"""Load durable transcript attribution, independently of replay payloads."""
|
|
115
|
+
with self.lock:
|
|
116
|
+
return {
|
|
117
|
+
row["session_id"]: {
|
|
118
|
+
"authors": set(json.loads(row["authors_json"])),
|
|
119
|
+
"explicit_author": bool(row["explicit_author"]),
|
|
120
|
+
"history_known": bool(row["history_known"]),
|
|
121
|
+
}
|
|
122
|
+
for row in self.db.execute(
|
|
123
|
+
"SELECT * FROM session_provenance WHERE collection=?"
|
|
124
|
+
+ (" AND session_id=?" if session_id is not None else ""),
|
|
125
|
+
(self.collection, session_id) if session_id is not None else (self.collection,),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def _save_session_provenance(self, session_id, value):
|
|
130
|
+
"""Save attribution within the caller's locked transaction."""
|
|
131
|
+
self.db.execute(
|
|
132
|
+
"INSERT OR REPLACE INTO session_provenance VALUES(?,?,?,?,?)",
|
|
133
|
+
(
|
|
134
|
+
session_id,
|
|
135
|
+
self.collection,
|
|
136
|
+
json.dumps(sorted(value["authors"])),
|
|
137
|
+
int(value["explicit_author"]),
|
|
138
|
+
int(value["history_known"]),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
def invalidate_transcript_history(self):
|
|
144
|
+
"""Require fresh host reset evidence after a provider ownership change.
|
|
145
|
+
|
|
146
|
+
The host may retain messages despite a failed callback or process interruption,
|
|
147
|
+
so durable observed authors alone cannot certify complete resumed history.
|
|
148
|
+
"""
|
|
149
|
+
with self.lock, self.db:
|
|
150
|
+
self.db.execute(
|
|
151
|
+
"UPDATE session_provenance SET history_known=0 WHERE collection=?",
|
|
152
|
+
(self.collection,),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def record_session_author(self, session_id, user_id, explicit=False, *, history_known=None):
|
|
156
|
+
"""Persist an observed principal without certifying unobserved history."""
|
|
157
|
+
with self.lock, self.db:
|
|
158
|
+
value = self.session_provenance(session_id).get(
|
|
159
|
+
session_id, {"authors": set(), "explicit_author": False, "history_known": False}
|
|
160
|
+
)
|
|
161
|
+
value["authors"].add(user_id)
|
|
162
|
+
value["explicit_author"] |= explicit
|
|
163
|
+
if history_known is not None:
|
|
164
|
+
value["history_known"] &= history_known
|
|
165
|
+
return self._save_session_provenance(session_id, value)
|
|
166
|
+
|
|
167
|
+
def reset_session_provenance(self, session_id, user_id, clear_quarantine=False):
|
|
168
|
+
"""Trust a fresh transcript only after the host explicitly discards history."""
|
|
169
|
+
with self.lock, self.db:
|
|
170
|
+
if clear_quarantine:
|
|
171
|
+
self.db.execute(
|
|
172
|
+
"DELETE FROM unattributed_sessions WHERE session_id=? AND collection=?",
|
|
173
|
+
(session_id, self.collection),
|
|
174
|
+
)
|
|
175
|
+
return self._save_session_provenance(
|
|
176
|
+
session_id,
|
|
177
|
+
{"authors": {user_id}, "explicit_author": False, "history_known": True},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def inherit_session_provenance(self, session_id, parent, *, history_known=None):
|
|
181
|
+
"""Durably union continuation history without upgrading unknown provenance."""
|
|
182
|
+
with self.lock, self.db:
|
|
183
|
+
source = self.session_provenance(parent).get(
|
|
184
|
+
parent, {"authors": set(), "explicit_author": False, "history_known": False}
|
|
185
|
+
)
|
|
186
|
+
target = self.session_provenance(session_id).get(session_id)
|
|
187
|
+
value = {
|
|
188
|
+
"authors": source["authors"] | (target["authors"] if target else set()),
|
|
189
|
+
"explicit_author": source["explicit_author"]
|
|
190
|
+
or bool(target and target["explicit_author"]),
|
|
191
|
+
"history_known": source["history_known"]
|
|
192
|
+
and (target["history_known"] if target else True)
|
|
193
|
+
and (history_known if history_known is not None else True),
|
|
194
|
+
}
|
|
195
|
+
if self.db.execute(
|
|
196
|
+
"SELECT 1 FROM unattributed_sessions WHERE session_id=? AND collection=?",
|
|
197
|
+
(parent, self.collection),
|
|
198
|
+
).fetchone():
|
|
199
|
+
self.db.execute(
|
|
200
|
+
"INSERT OR IGNORE INTO unattributed_sessions VALUES(?,?)",
|
|
201
|
+
(session_id, self.collection),
|
|
202
|
+
)
|
|
203
|
+
return self._save_session_provenance(session_id, value)
|
|
204
|
+
|
|
205
|
+
def enqueue_event(self, value):
|
|
206
|
+
"""Persist an immutable event once and return its deterministic key."""
|
|
207
|
+
key = digest([self.collection, value])
|
|
208
|
+
with self.lock, self.db:
|
|
209
|
+
self.db.execute(
|
|
210
|
+
"INSERT OR IGNORE INTO events(event_id,session_id,payload_json,created_at,collection) VALUES(?,?,?,?,?)",
|
|
211
|
+
(
|
|
212
|
+
key,
|
|
213
|
+
value["session_id"],
|
|
214
|
+
json.dumps(value, ensure_ascii=False),
|
|
215
|
+
now(),
|
|
216
|
+
self.collection,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
return key
|
|
220
|
+
|
|
221
|
+
def enqueue_operation(
|
|
222
|
+
self, identifier, action, value, source_id="", source_version="", generation=""
|
|
223
|
+
):
|
|
224
|
+
# Timestamps are part of migration metadata updates; runtime replay uses
|
|
225
|
+
# the same prepared operation persisted before the remote mutation.
|
|
226
|
+
"""Persist a prepared mutation and return its destination-scoped operation key.
|
|
227
|
+
|
|
228
|
+
A nonempty generation creates fresh migration repair work even when identical
|
|
229
|
+
payloads were committed earlier; the original source ID/version are retained.
|
|
230
|
+
"""
|
|
231
|
+
identity = [self.collection, identifier, action, value, source_id, source_version]
|
|
232
|
+
if generation:
|
|
233
|
+
identity.append(generation)
|
|
234
|
+
key = digest(identity)
|
|
235
|
+
with self.lock, self.db:
|
|
236
|
+
self.db.execute(
|
|
237
|
+
"""INSERT OR IGNORE INTO operations
|
|
238
|
+
(idempotency_key,point_id,action,payload_json,source_id,source_version,content_hash,created_at,collection)
|
|
239
|
+
VALUES(?,?,?,?,?,?,?,?,?)""",
|
|
240
|
+
(
|
|
241
|
+
key,
|
|
242
|
+
identifier,
|
|
243
|
+
action,
|
|
244
|
+
json.dumps(value, ensure_ascii=False),
|
|
245
|
+
source_id,
|
|
246
|
+
source_version,
|
|
247
|
+
value.get("content_hash", ""),
|
|
248
|
+
now(),
|
|
249
|
+
self.collection,
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
return key
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def delete_fence_source(scope):
|
|
256
|
+
"""Return a scope-bound marker for deletes that fence older admitted events."""
|
|
257
|
+
scope_value = scope.as_dict() if hasattr(scope, "as_dict") else dict(scope)
|
|
258
|
+
return DELETE_FENCE_PREFIX + digest(scope_value)
|
|
259
|
+
|
|
260
|
+
def is_delete_fenced(self, identifier, scope, admitted_at, candidate_hash=None):
|
|
261
|
+
"""Check whether a scoped delete supersedes an older point or content hash."""
|
|
262
|
+
source_id = self.delete_fence_source(scope)
|
|
263
|
+
with self.lock:
|
|
264
|
+
queries = [
|
|
265
|
+
self.db.execute(
|
|
266
|
+
"""SELECT source_version,created_at FROM operations
|
|
267
|
+
WHERE action='DELETE' AND source_id=? AND collection=?
|
|
268
|
+
AND point_id=?
|
|
269
|
+
ORDER BY rowid""",
|
|
270
|
+
(source_id, self.collection, identifier),
|
|
271
|
+
)
|
|
272
|
+
]
|
|
273
|
+
if candidate_hash:
|
|
274
|
+
queries.append(
|
|
275
|
+
self.db.execute(
|
|
276
|
+
"""SELECT source_version,created_at FROM operations
|
|
277
|
+
WHERE action='DELETE' AND source_id=? AND collection=?
|
|
278
|
+
AND content_hash=? ORDER BY rowid""",
|
|
279
|
+
(source_id, self.collection, candidate_hash),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
for row in chain.from_iterable(queries):
|
|
284
|
+
if not admitted_at:
|
|
285
|
+
return True
|
|
286
|
+
try:
|
|
287
|
+
event_time = datetime.fromisoformat(
|
|
288
|
+
timestamp(admitted_at).replace("Z", "+00:00")
|
|
289
|
+
)
|
|
290
|
+
delete_time = row["source_version"] or row["created_at"]
|
|
291
|
+
if not delete_time:
|
|
292
|
+
return True
|
|
293
|
+
delete_at = datetime.fromisoformat(
|
|
294
|
+
timestamp(delete_time).replace("Z", "+00:00")
|
|
295
|
+
)
|
|
296
|
+
if event_time <= delete_at:
|
|
297
|
+
return True
|
|
298
|
+
except (TypeError, ValueError, OverflowError):
|
|
299
|
+
return True
|
|
300
|
+
return False
|
|
301
|
+
finally:
|
|
302
|
+
for cursor in queries:
|
|
303
|
+
cursor.close()
|
|
304
|
+
|
|
305
|
+
def has_open_operations(self, identifier):
|
|
306
|
+
"""Check open writes to this point in this destination."""
|
|
307
|
+
with self.lock:
|
|
308
|
+
return (
|
|
309
|
+
self.db.execute(
|
|
310
|
+
"""SELECT 1 FROM operations WHERE point_id=? AND collection=?
|
|
311
|
+
AND status IN ('PENDING','FAILED') LIMIT 1""",
|
|
312
|
+
(identifier, self.collection),
|
|
313
|
+
).fetchone()
|
|
314
|
+
is not None
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def rows(self, table, status="PENDING"):
|
|
318
|
+
"""Return destination-scoped rows in insertion order for the requested status."""
|
|
319
|
+
if table not in {"events", "operations"}:
|
|
320
|
+
raise ValueError("Unknown ledger table")
|
|
321
|
+
with self.lock:
|
|
322
|
+
return [
|
|
323
|
+
dict(row)
|
|
324
|
+
for row in self.db.execute(
|
|
325
|
+
f"SELECT * FROM {table} WHERE status=? AND collection=? ORDER BY rowid",
|
|
326
|
+
(status, self.collection),
|
|
327
|
+
)
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
def high_watermark(self, table):
|
|
331
|
+
"""Snapshot a destination scan boundary before replay starts."""
|
|
332
|
+
if table not in {"events", "operations"}:
|
|
333
|
+
raise ValueError("Unknown ledger table")
|
|
334
|
+
with self.lock:
|
|
335
|
+
return self.db.execute(
|
|
336
|
+
f"SELECT COALESCE(MAX(rowid),0) FROM {table} WHERE collection=?",
|
|
337
|
+
(self.collection,),
|
|
338
|
+
).fetchone()[0]
|
|
339
|
+
|
|
340
|
+
def iter_rows(self, table, status="PENDING", batch_size=128, upper=None):
|
|
341
|
+
"""Yield bounded keyset batches without keeping a cursor or lock across yields.
|
|
342
|
+
|
|
343
|
+
The fixed upper rowid excludes concurrent admissions. State changes cannot
|
|
344
|
+
shift pages, and every row is returned at most once per scan.
|
|
345
|
+
"""
|
|
346
|
+
if table not in {"events", "operations"}:
|
|
347
|
+
raise ValueError("Unknown ledger table")
|
|
348
|
+
if batch_size < 1:
|
|
349
|
+
raise ValueError("Batch size must be positive")
|
|
350
|
+
upper = self.high_watermark(table) if upper is None else upper
|
|
351
|
+
after = 0
|
|
352
|
+
while after < upper:
|
|
353
|
+
with self.lock:
|
|
354
|
+
cursor = self.db.execute(
|
|
355
|
+
f"SELECT rowid AS scan_rowid,* FROM {table} "
|
|
356
|
+
"WHERE collection=? AND status=? AND rowid>? AND rowid<=? "
|
|
357
|
+
"ORDER BY rowid LIMIT ?",
|
|
358
|
+
(self.collection, status, after, upper, batch_size),
|
|
359
|
+
)
|
|
360
|
+
try:
|
|
361
|
+
batch = [dict(row) for row in cursor.fetchall()]
|
|
362
|
+
finally:
|
|
363
|
+
cursor.close()
|
|
364
|
+
if not batch:
|
|
365
|
+
return
|
|
366
|
+
after = batch[-1]["scan_rowid"]
|
|
367
|
+
yield from batch
|
|
368
|
+
|
|
369
|
+
def row(self, table, key):
|
|
370
|
+
"""Return an event/operation by key, or None when it is missing."""
|
|
371
|
+
column = {"events": "event_id", "operations": "idempotency_key"}[table]
|
|
372
|
+
with self.lock:
|
|
373
|
+
row = self.db.execute(f"SELECT * FROM {table} WHERE {column}=?", (key,)).fetchone()
|
|
374
|
+
return dict(row) if row else None
|
|
375
|
+
|
|
376
|
+
def count_pending_operations(self, keys):
|
|
377
|
+
"""Count requested pending keys in small batches without loading payloads.
|
|
378
|
+
|
|
379
|
+
Repeated keys retain their multiplicity, matching commit's progress contract.
|
|
380
|
+
Missing, terminal and other-destination operations contribute no count.
|
|
381
|
+
"""
|
|
382
|
+
total = 0
|
|
383
|
+
iterator = iter(keys)
|
|
384
|
+
while batch := list(islice(iterator, 128)):
|
|
385
|
+
multiplicities = Counter(batch)
|
|
386
|
+
parameters = list(multiplicities)
|
|
387
|
+
parameters.extend([None] * (128 - len(parameters)))
|
|
388
|
+
with self.lock:
|
|
389
|
+
# Otherwise SQLite can scan the whole pending collection for
|
|
390
|
+
# every key batch. This is the existing TEXT primary-key index.
|
|
391
|
+
cursor = self.db.execute(
|
|
392
|
+
"SELECT idempotency_key FROM operations INDEXED BY sqlite_autoindex_operations_1 "
|
|
393
|
+
"WHERE status='PENDING' "
|
|
394
|
+
"AND collection=? AND idempotency_key IN ("
|
|
395
|
+
"?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,"
|
|
396
|
+
"?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,"
|
|
397
|
+
"?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,"
|
|
398
|
+
"?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?"
|
|
399
|
+
")",
|
|
400
|
+
(self.collection, *parameters),
|
|
401
|
+
)
|
|
402
|
+
try:
|
|
403
|
+
total += sum(multiplicities[row[0]] for row in cursor)
|
|
404
|
+
finally:
|
|
405
|
+
cursor.close()
|
|
406
|
+
return total
|
|
407
|
+
|
|
408
|
+
def prepare_event(self, key, value):
|
|
409
|
+
"""Persist extraction or preparation progress before any service mutation."""
|
|
410
|
+
with self.lock, self.db:
|
|
411
|
+
self.db.execute(
|
|
412
|
+
"UPDATE events SET payload_json=? WHERE event_id=?",
|
|
413
|
+
(json.dumps(value, ensure_ascii=False), key),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def finish(self, table, key, status="COMMITTED"):
|
|
417
|
+
"""Acknowledge completion and supersede older open writes to the same point."""
|
|
418
|
+
column = {"events": "event_id", "operations": "idempotency_key"}[table]
|
|
419
|
+
with self.lock, self.db:
|
|
420
|
+
operation_keys = [key] if table == "operations" else []
|
|
421
|
+
self.db.execute(
|
|
422
|
+
f"UPDATE {table} SET status=?,last_error=NULL,committed_at=? WHERE {column}=?",
|
|
423
|
+
(status, now(), key),
|
|
424
|
+
)
|
|
425
|
+
if table == "operations" and status == "COMMITTED":
|
|
426
|
+
# An older failed write must never overwrite a later committed update.
|
|
427
|
+
current = self.db.execute(
|
|
428
|
+
"SELECT point_id,rowid FROM operations WHERE idempotency_key=? AND collection=?",
|
|
429
|
+
(key, self.collection),
|
|
430
|
+
).fetchone()
|
|
431
|
+
self.db.execute(
|
|
432
|
+
"""UPDATE operations SET status='SUPERSEDED' WHERE point_id=
|
|
433
|
+
(SELECT point_id FROM operations WHERE idempotency_key=?) AND rowid <
|
|
434
|
+
(SELECT rowid FROM operations WHERE idempotency_key=?) AND collection=? AND status IN ('PENDING','FAILED')""",
|
|
435
|
+
(key, key, self.collection),
|
|
436
|
+
)
|
|
437
|
+
if current:
|
|
438
|
+
operation_keys = chain(
|
|
439
|
+
operation_keys,
|
|
440
|
+
(
|
|
441
|
+
row["idempotency_key"]
|
|
442
|
+
for row in self._terminal_operation_rows(
|
|
443
|
+
current["point_id"], current["rowid"]
|
|
444
|
+
)
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
self._scrub_terminal_payloads(
|
|
448
|
+
event_keys=[key] if table == "events" else [],
|
|
449
|
+
operation_keys=operation_keys,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def failure(self, table, key, exc, terminal=False, count_attempt=True):
|
|
453
|
+
"""Record a sanitized failure and optionally increment its attempt count."""
|
|
454
|
+
column = {"events": "event_id", "operations": "idempotency_key"}[table]
|
|
455
|
+
with self.lock, self.db:
|
|
456
|
+
self.db.execute(
|
|
457
|
+
f"UPDATE {table} SET attempts=attempts+?,last_error=?,status=? WHERE {column}=?",
|
|
458
|
+
(
|
|
459
|
+
int(count_attempt),
|
|
460
|
+
json.dumps(safe_error(exc)),
|
|
461
|
+
"FAILED" if terminal else "PENDING",
|
|
462
|
+
key,
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def retry_failed(self):
|
|
467
|
+
"""Requeue failed events and operations only for this destination."""
|
|
468
|
+
with self.lock, self.db:
|
|
469
|
+
for table in ("events", "operations"):
|
|
470
|
+
self.db.execute(
|
|
471
|
+
f"UPDATE {table} SET status='PENDING' WHERE status='FAILED' AND collection=?",
|
|
472
|
+
(self.collection,),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def reset_operation(self, key):
|
|
476
|
+
"""Requeue one failed operation without touching other migration work."""
|
|
477
|
+
with self.lock, self.db:
|
|
478
|
+
self.db.execute(
|
|
479
|
+
"UPDATE operations SET status='PENDING' WHERE idempotency_key=? AND status='FAILED'",
|
|
480
|
+
(key,),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def stats(self):
|
|
484
|
+
"""Count destination-specific event states, operation states and attempts."""
|
|
485
|
+
with self.lock:
|
|
486
|
+
result = {
|
|
487
|
+
table: dict(
|
|
488
|
+
self.db.execute(
|
|
489
|
+
f"SELECT status,COUNT(*) FROM {table} WHERE collection=? GROUP BY status",
|
|
490
|
+
(self.collection,),
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
for table in ("events", "operations")
|
|
494
|
+
}
|
|
495
|
+
result["retry_total"] = self.db.execute(
|
|
496
|
+
"SELECT COALESCE(SUM(attempts),0) FROM operations WHERE collection=?",
|
|
497
|
+
(self.collection,),
|
|
498
|
+
).fetchone()[0]
|
|
499
|
+
result["dedupe"] = dict(
|
|
500
|
+
self.db.execute(
|
|
501
|
+
"SELECT name,value FROM counters WHERE collection=?", (self.collection,)
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
return result
|
|
505
|
+
|
|
506
|
+
def increment(self, name):
|
|
507
|
+
"""Increment a durable destination-specific decision counter."""
|
|
508
|
+
with self.lock, self.db:
|
|
509
|
+
self.db.execute(
|
|
510
|
+
"INSERT INTO counters VALUES(?,1,?) ON CONFLICT(name,collection) DO UPDATE SET value=value+1",
|
|
511
|
+
(name, self.collection),
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def save_manifest(self, value):
|
|
515
|
+
"""Durably store one destination-specific migration manifest."""
|
|
516
|
+
with self.lock, self.db:
|
|
517
|
+
self.db.execute(
|
|
518
|
+
"INSERT OR REPLACE INTO manifests VALUES(?,?,?)",
|
|
519
|
+
(value["migration_id"], json.dumps(value), self.collection),
|
|
520
|
+
)
|
|
521
|
+
self._resumable_operation_keys = self._load_resumable_operation_keys()
|
|
522
|
+
self._scrub_terminal_payloads()
|
|
523
|
+
|
|
524
|
+
def scrub_terminal_payloads(self):
|
|
525
|
+
"""Scrub completed event and operation bodies while retaining dedupe rows."""
|
|
526
|
+
with self.lock, self.db:
|
|
527
|
+
return self._scrub_terminal_payloads()
|
|
528
|
+
|
|
529
|
+
def _load_resumable_operation_keys(self):
|
|
530
|
+
"""Find operations referenced by manifests that have not completed."""
|
|
531
|
+
keys = set()
|
|
532
|
+
for row in self.db.execute(
|
|
533
|
+
"SELECT payload_json FROM manifests WHERE collection=?", (self.collection,)
|
|
534
|
+
):
|
|
535
|
+
try:
|
|
536
|
+
manifest = json.loads(row["payload_json"])
|
|
537
|
+
except (TypeError, ValueError):
|
|
538
|
+
return None
|
|
539
|
+
if not isinstance(manifest, dict):
|
|
540
|
+
return None
|
|
541
|
+
if manifest.get("completed_at") is not None:
|
|
542
|
+
continue
|
|
543
|
+
records = manifest.get("records", {})
|
|
544
|
+
if not isinstance(records, dict):
|
|
545
|
+
return None
|
|
546
|
+
for record in records.values():
|
|
547
|
+
if not isinstance(record, dict):
|
|
548
|
+
return None
|
|
549
|
+
key = record.get("operation_key")
|
|
550
|
+
if key:
|
|
551
|
+
keys.add(key)
|
|
552
|
+
return keys
|
|
553
|
+
|
|
554
|
+
def _scrub_terminal_payloads(self, event_keys=None, operation_keys=None):
|
|
555
|
+
"""Scrub terminal bodies except operations still needed by an open migration."""
|
|
556
|
+
if event_keys is None:
|
|
557
|
+
event_count = self.db.execute(
|
|
558
|
+
"""UPDATE events SET payload_json='{}'
|
|
559
|
+
WHERE collection=? AND status IN ('COMMITTED','SUPERSEDED')
|
|
560
|
+
AND payload_json!='{}'""",
|
|
561
|
+
(self.collection,),
|
|
562
|
+
).rowcount
|
|
563
|
+
else:
|
|
564
|
+
event_count = sum(
|
|
565
|
+
self.db.execute(
|
|
566
|
+
"""UPDATE events SET payload_json='{}'
|
|
567
|
+
WHERE event_id=? AND collection=?
|
|
568
|
+
AND status IN ('COMMITTED','SUPERSEDED')
|
|
569
|
+
AND payload_json!='{}'""",
|
|
570
|
+
(key, self.collection),
|
|
571
|
+
).rowcount
|
|
572
|
+
for key in event_keys
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
resumable_keys = self._resumable_operation_keys
|
|
576
|
+
if resumable_keys is None:
|
|
577
|
+
return {"events": event_count, "operations": 0}
|
|
578
|
+
if operation_keys is None:
|
|
579
|
+
rows = self._terminal_operation_rows()
|
|
580
|
+
else:
|
|
581
|
+
|
|
582
|
+
def selected_rows():
|
|
583
|
+
"""Read one acknowledgement at a time, including superseded history."""
|
|
584
|
+
for key in operation_keys:
|
|
585
|
+
row = self.db.execute(
|
|
586
|
+
"""SELECT idempotency_key,payload_json FROM operations
|
|
587
|
+
WHERE idempotency_key=? AND collection=?
|
|
588
|
+
AND status IN ('COMMITTED','SUPERSEDED')""",
|
|
589
|
+
(key, self.collection),
|
|
590
|
+
).fetchone()
|
|
591
|
+
if row:
|
|
592
|
+
yield row
|
|
593
|
+
|
|
594
|
+
rows = selected_rows()
|
|
595
|
+
operation_count = 0
|
|
596
|
+
for row in rows:
|
|
597
|
+
key = row["idempotency_key"]
|
|
598
|
+
if key in resumable_keys or row["payload_json"] == "{}":
|
|
599
|
+
continue
|
|
600
|
+
operation_count += self.db.execute(
|
|
601
|
+
"""UPDATE operations SET payload_json='{}'
|
|
602
|
+
WHERE idempotency_key=? AND collection=?
|
|
603
|
+
AND status IN ('COMMITTED','SUPERSEDED')""",
|
|
604
|
+
(key, self.collection),
|
|
605
|
+
).rowcount
|
|
606
|
+
return {"events": event_count, "operations": operation_count}
|
|
607
|
+
|
|
608
|
+
def _terminal_operation_rows(self, point=None, before=None):
|
|
609
|
+
"""Read scrub candidates in stable pages, closing queries before mutations."""
|
|
610
|
+
upper = self.high_watermark("operations") if before is None else before - 1
|
|
611
|
+
after = 0
|
|
612
|
+
while after < upper:
|
|
613
|
+
with self.lock:
|
|
614
|
+
if point is None:
|
|
615
|
+
cursor = self.db.execute(
|
|
616
|
+
"""SELECT rowid AS scan_rowid,idempotency_key,payload_json
|
|
617
|
+
FROM operations INDEXED BY operations_unscrubbed_by_collection
|
|
618
|
+
WHERE collection=? AND status IN ('COMMITTED','SUPERSEDED')
|
|
619
|
+
AND payload_json!='{}' AND rowid>? AND rowid<=?
|
|
620
|
+
ORDER BY rowid LIMIT 128""",
|
|
621
|
+
(self.collection, after, upper),
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
cursor = self.db.execute(
|
|
625
|
+
"""SELECT rowid AS scan_rowid,idempotency_key,payload_json
|
|
626
|
+
FROM operations INDEXED BY operations_unscrubbed_by_point
|
|
627
|
+
WHERE collection=? AND status IN ('COMMITTED','SUPERSEDED')
|
|
628
|
+
AND payload_json!='{}' AND rowid>? AND rowid<=? AND point_id=?
|
|
629
|
+
ORDER BY rowid LIMIT 128""",
|
|
630
|
+
(self.collection, after, upper, point),
|
|
631
|
+
)
|
|
632
|
+
try:
|
|
633
|
+
rows = cursor.fetchall()
|
|
634
|
+
finally:
|
|
635
|
+
cursor.close()
|
|
636
|
+
if not rows:
|
|
637
|
+
return
|
|
638
|
+
after = rows[-1]["scan_rowid"]
|
|
639
|
+
yield from rows
|
|
640
|
+
|
|
641
|
+
def manifests(self):
|
|
642
|
+
"""Read this destination's manifests in insertion order."""
|
|
643
|
+
with self.lock:
|
|
644
|
+
return [
|
|
645
|
+
json.loads(row[0])
|
|
646
|
+
for row in self.db.execute(
|
|
647
|
+
"SELECT payload_json FROM manifests WHERE collection=? ORDER BY rowid",
|
|
648
|
+
(self.collection,),
|
|
649
|
+
)
|
|
650
|
+
]
|
|
651
|
+
|
|
652
|
+
def measure(self, name, value):
|
|
653
|
+
"""Record a metric sample, retaining at most 512 per name/destination."""
|
|
654
|
+
with self.lock, self.db:
|
|
655
|
+
self.db.execute("INSERT INTO metrics VALUES(?,?,?)", (name, value, self.collection))
|
|
656
|
+
self.db.execute(
|
|
657
|
+
"""DELETE FROM metrics WHERE name=? AND collection=? AND rowid NOT IN
|
|
658
|
+
(SELECT rowid FROM metrics WHERE name=? AND collection=? ORDER BY rowid DESC LIMIT 512)""",
|
|
659
|
+
(name, self.collection, name, self.collection),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
def metrics(self):
|
|
663
|
+
"""Summarize retained samples with counts and empirical p50/p95 values."""
|
|
664
|
+
with self.lock:
|
|
665
|
+
samples = list(
|
|
666
|
+
self.db.execute(
|
|
667
|
+
"SELECT name,value FROM metrics WHERE collection=?", (self.collection,)
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
groups = {}
|
|
671
|
+
for name, value in samples:
|
|
672
|
+
groups.setdefault(name, []).append(value)
|
|
673
|
+
result = {}
|
|
674
|
+
for name, values in groups.items():
|
|
675
|
+
values.sort()
|
|
676
|
+
result[name] = {
|
|
677
|
+
"samples": len(values),
|
|
678
|
+
"p50": values[(len(values) - 1) // 2],
|
|
679
|
+
"p95": values[min(len(values) - 1, int(len(values) * 0.95))],
|
|
680
|
+
}
|
|
681
|
+
return result
|
|
682
|
+
|
|
683
|
+
def close(self):
|
|
684
|
+
"""Close SQLite while excluding concurrent ledger access."""
|
|
685
|
+
with self.lock:
|
|
686
|
+
self.db.close()
|