context-engineering-core 0.12.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.
- context_engine/__init__.py +15 -0
- context_engine/__main__.py +3 -0
- context_engine/_sqlite.py +19 -0
- context_engine/_windows_security.py +125 -0
- context_engine/_windows_storage.py +110 -0
- context_engine/adapters.py +148 -0
- context_engine/answers.py +90 -0
- context_engine/budget.py +93 -0
- context_engine/cli.py +222 -0
- context_engine/config.py +193 -0
- context_engine/context.py +116 -0
- context_engine/demo.py +98 -0
- context_engine/errors.py +91 -0
- context_engine/evaluation/__init__.py +1 -0
- context_engine/evaluation/commands.py +90 -0
- context_engine/evaluation/corpus.py +247 -0
- context_engine/evaluation/data/freeze.json +26 -0
- context_engine/evaluation/data/ground_truth.json +287 -0
- context_engine/evaluation/data/pin_retention.json +8 -0
- context_engine/evaluation/data/protocol.json +122 -0
- context_engine/evaluation/data/scenario.json +1226 -0
- context_engine/evaluation/data/summary_fixture.json +7 -0
- context_engine/evaluation/execution.py +538 -0
- context_engine/evaluation/fixtures.py +32 -0
- context_engine/evaluation/grading.py +62 -0
- context_engine/evaluation/harness.py +165 -0
- context_engine/evaluation/heldout/__init__.py +260 -0
- context_engine/evaluation/heldout/__main__.py +40 -0
- context_engine/evaluation/heldout/data/ground_truth.json +18 -0
- context_engine/evaluation/heldout/data/manifest.json +9 -0
- context_engine/evaluation/heldout/data/protocol.json +14 -0
- context_engine/evaluation/heldout/data/scenarios.json +18 -0
- context_engine/evaluation/heldout/data/scoring_manifest.json +4 -0
- context_engine/evaluation/heldout/data/scoring_protocol.json +12 -0
- context_engine/evaluation/heldout/scoring.py +176 -0
- context_engine/evaluation/protocol.py +264 -0
- context_engine/evaluation/reporting.py +294 -0
- context_engine/evaluation/results.py +331 -0
- context_engine/evaluation/run_commands.py +146 -0
- context_engine/evaluation/strategies.py +261 -0
- context_engine/inspection.py +266 -0
- context_engine/layers/__init__.py +18 -0
- context_engine/layers/cap.py +99 -0
- context_engine/layers/common.py +162 -0
- context_engine/layers/pin.py +68 -0
- context_engine/layers/retrieve.py +233 -0
- context_engine/layers/summarize.py +202 -0
- context_engine/layers/window.py +29 -0
- context_engine/mcp_server.py +267 -0
- context_engine/memory/__init__.py +5 -0
- context_engine/memory/codec.py +91 -0
- context_engine/memory/demo.py +90 -0
- context_engine/memory/store.py +770 -0
- context_engine/models.py +384 -0
- context_engine/pipeline.py +383 -0
- context_engine/providers/__init__.py +1 -0
- context_engine/providers/client.py +301 -0
- context_engine/providers/commands.py +138 -0
- context_engine/providers/contracts.py +324 -0
- context_engine/providers/credentials.py +66 -0
- context_engine/providers/memory.py +112 -0
- context_engine/providers/store.py +408 -0
- context_engine/providers/transport.py +195 -0
- context_engine/providers/windows_credentials.py +23 -0
- context_engine/py.typed +0 -0
- context_engine/service/__init__.py +1 -0
- context_engine/service/__main__.py +68 -0
- context_engine/service/app.py +404 -0
- context_engine/service/auth.py +132 -0
- context_engine/service/client.py +52 -0
- context_engine/service/control.py +134 -0
- context_engine/service/demo.py +151 -0
- context_engine/service/integrations.py +28 -0
- context_engine/service/schemas.py +59 -0
- context_engine/service/worker.py +59 -0
- context_engine/service/workers.py +156 -0
- context_engine/summaries.py +196 -0
- context_engine/tokens.py +171 -0
- context_engine/work.py +46 -0
- context_engineering_core-0.12.0.dist-info/METADATA +203 -0
- context_engineering_core-0.12.0.dist-info/RECORD +84 -0
- context_engineering_core-0.12.0.dist-info/WHEEL +4 -0
- context_engineering_core-0.12.0.dist-info/entry_points.txt +3 -0
- context_engineering_core-0.12.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
"""Scoped transactional memory, resumable chunk indexes and deletion-aware local recovery."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import stat
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from contextlib import closing, contextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .._sqlite import connect
|
|
14
|
+
from ..config import RetrievalConfig
|
|
15
|
+
from ..errors import ContractError, MemoryConflict, MemoryIntegrityError, StorageError
|
|
16
|
+
from ..layers.common import history_digest
|
|
17
|
+
from ..layers.retrieve import ChunkIndex, chunk_turns
|
|
18
|
+
from ..layers.summarize import FrozenSummaryPolicy, PrefixSummaryPolicy, SummarySnapshot
|
|
19
|
+
from ..models import ChatRequest, KeyedPins, Pin, Scope, Turn, aware_time, integer_value, text_value
|
|
20
|
+
from ..pipeline import assemble_context
|
|
21
|
+
from ..work import raise_if_cancelled, validate_cancellation
|
|
22
|
+
from .codec import decode, digest, encode, plain, unpack
|
|
23
|
+
|
|
24
|
+
MAX_TURNS, MAX_BYTES, MAX_CHUNKS = 10000, 8_000_000, 100000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def private_file(path, *, create=False, exclusive=False):
|
|
28
|
+
if os.name == "nt":
|
|
29
|
+
from .._windows_storage import private_file as windows_private_file
|
|
30
|
+
|
|
31
|
+
return windows_private_file(path, create=create, exclusive=exclusive)
|
|
32
|
+
try:
|
|
33
|
+
path = Path(path).absolute()
|
|
34
|
+
if create:
|
|
35
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
36
|
+
flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)
|
|
37
|
+
if create:
|
|
38
|
+
flags |= os.O_CREAT
|
|
39
|
+
if exclusive:
|
|
40
|
+
flags |= os.O_EXCL
|
|
41
|
+
descriptor = os.open(path, flags, 0o600)
|
|
42
|
+
try:
|
|
43
|
+
info = os.fstat(descriptor)
|
|
44
|
+
if not stat.S_ISREG(info.st_mode) or (os.name == "posix" and info.st_mode & 0o077):
|
|
45
|
+
raise StorageError("Memory files must be private regular files")
|
|
46
|
+
finally:
|
|
47
|
+
os.close(descriptor)
|
|
48
|
+
return path
|
|
49
|
+
except (OSError, ValueError, TypeError):
|
|
50
|
+
raise StorageError("Cannot open private memory file") from None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class MemorySnapshot:
|
|
55
|
+
scope: Scope
|
|
56
|
+
revision: int
|
|
57
|
+
snapshot_id: str
|
|
58
|
+
history: tuple[Turn, ...]
|
|
59
|
+
pins: KeyedPins
|
|
60
|
+
summary: SummarySnapshot | None
|
|
61
|
+
at: datetime
|
|
62
|
+
|
|
63
|
+
def to_dict(self, *, include_content=False):
|
|
64
|
+
if type(include_content) is not bool:
|
|
65
|
+
raise MemoryIntegrityError("Explicit content export must be boolean")
|
|
66
|
+
result = {
|
|
67
|
+
"scope": plain(self.scope),
|
|
68
|
+
"revision": self.revision,
|
|
69
|
+
"snapshot_id": self.snapshot_id,
|
|
70
|
+
"turns": len(self.history),
|
|
71
|
+
"pins": len(self.pins.pins),
|
|
72
|
+
"summary_available": self.summary is not None,
|
|
73
|
+
"content_included": include_content,
|
|
74
|
+
}
|
|
75
|
+
if include_content:
|
|
76
|
+
result.update(
|
|
77
|
+
history=plain(self.history),
|
|
78
|
+
pinned_facts=plain(self.pins),
|
|
79
|
+
summary=plain(self.summary),
|
|
80
|
+
)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MemoryStore:
|
|
85
|
+
def __init__(self, path, *, deletion_path=None, clock=None):
|
|
86
|
+
self.clock = clock or (lambda: datetime.now(UTC))
|
|
87
|
+
path = Path(path).absolute()
|
|
88
|
+
deletion_path = (
|
|
89
|
+
Path(deletion_path).absolute()
|
|
90
|
+
if deletion_path
|
|
91
|
+
else path.with_suffix(".deletions.sqlite")
|
|
92
|
+
)
|
|
93
|
+
if path == deletion_path:
|
|
94
|
+
raise StorageError("Memory and deletion authority require distinct files")
|
|
95
|
+
creating = not path.exists()
|
|
96
|
+
if creating and deletion_path.exists():
|
|
97
|
+
raise StorageError("Existing deletion authority requires explicit restore")
|
|
98
|
+
self.path = private_file(path, create=creating, exclusive=creating)
|
|
99
|
+
self.deletion_path = private_file(deletion_path, create=creating, exclusive=creating)
|
|
100
|
+
with self._transaction() as db:
|
|
101
|
+
version = db.execute("PRAGMA main.user_version").fetchone()[0]
|
|
102
|
+
dv = db.execute("PRAGMA deletions.user_version").fetchone()[0]
|
|
103
|
+
if creating:
|
|
104
|
+
lineage = uuid.uuid4().hex
|
|
105
|
+
db.execute(
|
|
106
|
+
"CREATE TABLE meta(lineage TEXT, epoch TEXT, applied INTEGER, last_clock REAL)"
|
|
107
|
+
)
|
|
108
|
+
db.execute("INSERT INTO meta VALUES(?,?,0,0)", (lineage, uuid.uuid4().hex))
|
|
109
|
+
db.execute("CREATE TABLE deletions.authority(lineage TEXT)")
|
|
110
|
+
db.execute("INSERT INTO deletions.authority VALUES(?)", (lineage,))
|
|
111
|
+
db.execute(
|
|
112
|
+
"CREATE TABLE deletions.events(seq INTEGER PRIMARY KEY AUTOINCREMENT, "
|
|
113
|
+
"scope TEXT, kind TEXT, key TEXT, UNIQUE(scope,kind,key))"
|
|
114
|
+
)
|
|
115
|
+
for sql in (
|
|
116
|
+
"CREATE TABLE scopes(scope TEXT PRIMARY KEY, revision INTEGER, "
|
|
117
|
+
"deleted INTEGER DEFAULT 0)",
|
|
118
|
+
"CREATE TABLE turns(scope TEXT, key TEXT, position INTEGER, revision INTEGER, "
|
|
119
|
+
"payload TEXT, digest TEXT, expires REAL, PRIMARY KEY(scope,key))",
|
|
120
|
+
"CREATE TABLE pins(scope TEXT, key TEXT, revision INTEGER, payload TEXT, "
|
|
121
|
+
"digest TEXT, expires REAL, PRIMARY KEY(scope,key))",
|
|
122
|
+
"CREATE TABLE summaries(scope TEXT PRIMARY KEY, payload TEXT, "
|
|
123
|
+
"digest TEXT, expires REAL)",
|
|
124
|
+
"CREATE TABLE operations(scope TEXT, key TEXT, digest TEXT, "
|
|
125
|
+
"result INTEGER, PRIMARY KEY(scope,key))",
|
|
126
|
+
"CREATE TABLE chunks(scope TEXT, profile TEXT, turn_id TEXT, key TEXT, "
|
|
127
|
+
"payload TEXT, PRIMARY KEY(scope,profile,key))",
|
|
128
|
+
"CREATE TABLE coverage(scope TEXT, profile TEXT, turn_id TEXT, "
|
|
129
|
+
"source_hash TEXT, chunks_hash TEXT, PRIMARY KEY(scope,profile,turn_id))",
|
|
130
|
+
"CREATE TABLE replay(scope TEXT, key TEXT, snapshot TEXT, payload TEXT, "
|
|
131
|
+
"digest TEXT, expires REAL, PRIMARY KEY(scope,key))",
|
|
132
|
+
):
|
|
133
|
+
db.execute(sql)
|
|
134
|
+
db.execute("PRAGMA main.user_version=1")
|
|
135
|
+
db.execute("PRAGMA deletions.user_version=1")
|
|
136
|
+
elif (version, dv) != (1, 1):
|
|
137
|
+
raise StorageError("Unsupported memory/deletion schema; migration required")
|
|
138
|
+
self._maintain(db)
|
|
139
|
+
|
|
140
|
+
@contextmanager
|
|
141
|
+
def _transaction(self):
|
|
142
|
+
db = None
|
|
143
|
+
try:
|
|
144
|
+
private_file(self.path)
|
|
145
|
+
private_file(self.deletion_path)
|
|
146
|
+
db = connect(self.path, timeout=0.5, isolation_level=None)
|
|
147
|
+
db.row_factory = sqlite3.Row
|
|
148
|
+
db.execute("ATTACH DATABASE ? AS deletions", (str(self.deletion_path),))
|
|
149
|
+
for schema in ("main", "deletions"):
|
|
150
|
+
mode = db.execute(f"PRAGMA {schema}.journal_mode").fetchone()[0]
|
|
151
|
+
if mode != "delete":
|
|
152
|
+
raise StorageError(
|
|
153
|
+
"Atomic memory/deletion transactions require DELETE journal mode"
|
|
154
|
+
)
|
|
155
|
+
db.execute(f"PRAGMA {schema}.synchronous=FULL")
|
|
156
|
+
db.execute(f"PRAGMA {schema}.secure_delete=ON")
|
|
157
|
+
db.execute("BEGIN IMMEDIATE")
|
|
158
|
+
yield db
|
|
159
|
+
db.execute("COMMIT")
|
|
160
|
+
except sqlite3.Error:
|
|
161
|
+
if db is not None and db.in_transaction:
|
|
162
|
+
db.rollback()
|
|
163
|
+
raise StorageError("Memory database unavailable or corrupt") from None
|
|
164
|
+
except BaseException:
|
|
165
|
+
if db is not None and db.in_transaction:
|
|
166
|
+
db.rollback()
|
|
167
|
+
raise
|
|
168
|
+
finally:
|
|
169
|
+
if db is not None:
|
|
170
|
+
db.close()
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _scope(scope):
|
|
174
|
+
if not isinstance(scope, Scope):
|
|
175
|
+
raise MemoryIntegrityError("Every memory operation requires an explicit Scope")
|
|
176
|
+
return encode(scope)
|
|
177
|
+
|
|
178
|
+
def _maintain(self, db):
|
|
179
|
+
now = self.clock()
|
|
180
|
+
aware_time("clock", now)
|
|
181
|
+
stamp = now.timestamp()
|
|
182
|
+
meta = db.execute("SELECT * FROM meta").fetchall()
|
|
183
|
+
authority = db.execute("SELECT * FROM deletions.authority").fetchall()
|
|
184
|
+
if len(meta) != 1 or len(authority) != 1 or meta[0]["lineage"] != authority[0]["lineage"]:
|
|
185
|
+
raise MemoryIntegrityError("Deletion authority does not match this memory store")
|
|
186
|
+
meta = meta[0]
|
|
187
|
+
if stamp < meta["last_clock"]:
|
|
188
|
+
raise MemoryConflict("Memory clock moved backwards")
|
|
189
|
+
maximum, count = db.execute(
|
|
190
|
+
"SELECT coalesce(max(seq),0),count(*) FROM deletions.events"
|
|
191
|
+
).fetchone()
|
|
192
|
+
if maximum != count or maximum < meta["applied"]:
|
|
193
|
+
raise MemoryIntegrityError("Deletion authority is incomplete or rolled back")
|
|
194
|
+
for event in db.execute(
|
|
195
|
+
"SELECT * FROM deletions.events WHERE seq>? ORDER BY seq", (meta["applied"],)
|
|
196
|
+
).fetchall():
|
|
197
|
+
self._erase(db, event["scope"], event["kind"], event["key"])
|
|
198
|
+
db.execute(
|
|
199
|
+
"UPDATE meta SET applied=(SELECT coalesce(max(seq),0) FROM deletions.events),"
|
|
200
|
+
"last_clock=?",
|
|
201
|
+
(stamp,),
|
|
202
|
+
)
|
|
203
|
+
for table, kind in (("turns", "turn"), ("pins", "pin")):
|
|
204
|
+
expired = db.execute(
|
|
205
|
+
f"SELECT scope,key FROM {table} WHERE expires IS NOT NULL AND expires<=?", (stamp,)
|
|
206
|
+
).fetchall()
|
|
207
|
+
for row in expired:
|
|
208
|
+
self._delete(db, row["scope"], kind, row["key"])
|
|
209
|
+
for row in db.execute(
|
|
210
|
+
"SELECT scope FROM summaries WHERE expires IS NOT NULL AND expires<=?", (stamp,)
|
|
211
|
+
).fetchall():
|
|
212
|
+
self._changed(db, row["scope"])
|
|
213
|
+
db.execute("DELETE FROM replay WHERE expires<=?", (stamp,))
|
|
214
|
+
return now
|
|
215
|
+
|
|
216
|
+
def _active(self, db, scope, expected=None):
|
|
217
|
+
row = db.execute("SELECT * FROM scopes WHERE scope=?", (scope,)).fetchone()
|
|
218
|
+
if row is None:
|
|
219
|
+
raise MemoryConflict("Memory scope does not exist")
|
|
220
|
+
if row["deleted"]:
|
|
221
|
+
raise MemoryConflict("Memory scope is permanently deleted")
|
|
222
|
+
if expected is not None:
|
|
223
|
+
integer_value("expected_revision", expected)
|
|
224
|
+
if row["revision"] != expected:
|
|
225
|
+
raise MemoryConflict("Memory scope revision changed")
|
|
226
|
+
return row["revision"]
|
|
227
|
+
|
|
228
|
+
def _changed(self, db, scope, turn_id=None):
|
|
229
|
+
db.execute("UPDATE scopes SET revision=revision+1 WHERE scope=?", (scope,))
|
|
230
|
+
summary = db.execute("SELECT * FROM summaries WHERE scope=?", (scope,)).fetchone()
|
|
231
|
+
# A summary survives only appends of turns it does not cover; reads re-verify lineage.
|
|
232
|
+
if (
|
|
233
|
+
summary is not None
|
|
234
|
+
and turn_id is not None
|
|
235
|
+
and turn_id not in self._read("summary", summary).covered_turn_ids
|
|
236
|
+
):
|
|
237
|
+
summary = None
|
|
238
|
+
if summary is not None:
|
|
239
|
+
db.execute("DELETE FROM summaries WHERE scope=?", (scope,))
|
|
240
|
+
db.execute("DELETE FROM replay WHERE scope=?", (scope,))
|
|
241
|
+
if turn_id is not None:
|
|
242
|
+
db.execute("DELETE FROM chunks WHERE scope=? AND turn_id=?", (scope, turn_id))
|
|
243
|
+
db.execute("DELETE FROM coverage WHERE scope=? AND turn_id=?", (scope, turn_id))
|
|
244
|
+
for row in db.execute(
|
|
245
|
+
"SELECT key,payload FROM pins WHERE scope=?", (scope,)
|
|
246
|
+
).fetchall():
|
|
247
|
+
pin = unpack("pin", row["payload"])
|
|
248
|
+
if pin.source and pin.source.turn_id == turn_id:
|
|
249
|
+
# Tombstone derived pins as well, so an older backup cannot revive them.
|
|
250
|
+
self._delete(db, scope, "pin", row["key"])
|
|
251
|
+
|
|
252
|
+
def _erase(self, db, scope, kind, key):
|
|
253
|
+
if kind not in ("scope", "turn", "pin"):
|
|
254
|
+
raise MemoryIntegrityError("Unknown deletion record")
|
|
255
|
+
if kind == "scope":
|
|
256
|
+
db.execute("INSERT OR IGNORE INTO scopes VALUES(?,0,1)", (scope,))
|
|
257
|
+
for table in (
|
|
258
|
+
"turns",
|
|
259
|
+
"pins",
|
|
260
|
+
"summaries",
|
|
261
|
+
"chunks",
|
|
262
|
+
"coverage",
|
|
263
|
+
"replay",
|
|
264
|
+
"operations",
|
|
265
|
+
):
|
|
266
|
+
db.execute(f"DELETE FROM {table} WHERE scope=?", (scope,))
|
|
267
|
+
db.execute("UPDATE scopes SET deleted=1,revision=revision+1 WHERE scope=?", (scope,))
|
|
268
|
+
else:
|
|
269
|
+
table = "turns" if kind == "turn" else "pins"
|
|
270
|
+
db.execute(f"DELETE FROM {table} WHERE scope=? AND key=?", (scope, key))
|
|
271
|
+
self._changed(db, scope, key if kind == "turn" else None)
|
|
272
|
+
|
|
273
|
+
def _delete(self, db, scope, kind, key):
|
|
274
|
+
db.execute(
|
|
275
|
+
"INSERT INTO deletions.events(scope,kind,key) SELECT ?,?,? WHERE NOT EXISTS"
|
|
276
|
+
"(SELECT 1 FROM deletions.events WHERE scope=? AND kind=? AND key=?)",
|
|
277
|
+
(scope, kind, key, scope, kind, key),
|
|
278
|
+
)
|
|
279
|
+
self._erase(db, scope, kind, key)
|
|
280
|
+
db.execute("UPDATE meta SET applied=(SELECT coalesce(max(seq),0) FROM deletions.events)")
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _not_deleted(db, scope, kind, key):
|
|
284
|
+
if db.execute(
|
|
285
|
+
"SELECT 1 FROM deletions.events WHERE scope=? AND kind=? AND key=?", (scope, kind, key)
|
|
286
|
+
).fetchone():
|
|
287
|
+
raise MemoryConflict("Deleted identifiers cannot be reused")
|
|
288
|
+
|
|
289
|
+
def create_scope(self, scope):
|
|
290
|
+
scope = self._scope(scope)
|
|
291
|
+
with self._transaction() as db:
|
|
292
|
+
self._maintain(db)
|
|
293
|
+
self._not_deleted(db, scope, "scope", "")
|
|
294
|
+
db.execute("INSERT OR IGNORE INTO scopes VALUES(?,0,0)", (scope,))
|
|
295
|
+
return self._active(db, scope)
|
|
296
|
+
|
|
297
|
+
def put_turn(self, turn, *, operation_id, expected_revision, expires_at=None):
|
|
298
|
+
if not isinstance(turn, Turn):
|
|
299
|
+
raise MemoryIntegrityError("Ingestion requires a validated Turn")
|
|
300
|
+
ChatRequest(turn.messages) # Complete tool-call pairs must be ingested atomically.
|
|
301
|
+
text_value("operation_id", operation_id)
|
|
302
|
+
integer_value("expected_revision", expected_revision)
|
|
303
|
+
if expires_at is not None:
|
|
304
|
+
aware_time("expires_at", expires_at)
|
|
305
|
+
payload = encode(turn)
|
|
306
|
+
if len(payload.encode()) > 1_000_000:
|
|
307
|
+
raise MemoryIntegrityError("Turn exceeds persistent payload limit")
|
|
308
|
+
scope = self._scope(turn.scope)
|
|
309
|
+
operation_hash = digest([plain(turn), expires_at])
|
|
310
|
+
with self._transaction() as db:
|
|
311
|
+
now = self._maintain(db)
|
|
312
|
+
self._active(db, scope)
|
|
313
|
+
old_op = db.execute(
|
|
314
|
+
"SELECT * FROM operations WHERE scope=? AND key=?", (scope, operation_id)
|
|
315
|
+
).fetchone()
|
|
316
|
+
if old_op:
|
|
317
|
+
if old_op["digest"] != operation_hash:
|
|
318
|
+
raise MemoryConflict("Idempotency key reused with different input")
|
|
319
|
+
return old_op["result"]
|
|
320
|
+
self._active(db, scope, expected_revision)
|
|
321
|
+
self._not_deleted(db, scope, "turn", turn.turn_id)
|
|
322
|
+
if expires_at is not None and expires_at <= now:
|
|
323
|
+
raise MemoryIntegrityError("Cannot ingest already expired content")
|
|
324
|
+
old = db.execute(
|
|
325
|
+
"SELECT * FROM turns WHERE scope=? AND key=?", (scope, turn.turn_id)
|
|
326
|
+
).fetchone()
|
|
327
|
+
if turn.revision != (old["revision"] + 1 if old else 1):
|
|
328
|
+
raise MemoryConflict("Turn revision must advance exactly once")
|
|
329
|
+
count, size = db.execute(
|
|
330
|
+
"SELECT count(*),coalesce(sum(length(CAST(payload AS BLOB))),0) "
|
|
331
|
+
"FROM turns WHERE scope=?",
|
|
332
|
+
(scope,),
|
|
333
|
+
).fetchone()
|
|
334
|
+
if (
|
|
335
|
+
count + (old is None) > MAX_TURNS
|
|
336
|
+
or size + len(payload.encode()) - (len(old["payload"].encode()) if old else 0)
|
|
337
|
+
> MAX_BYTES
|
|
338
|
+
):
|
|
339
|
+
raise MemoryIntegrityError("Memory scope exceeds retention capacity")
|
|
340
|
+
position = (
|
|
341
|
+
old["position"]
|
|
342
|
+
if old
|
|
343
|
+
else db.execute(
|
|
344
|
+
"SELECT coalesce(max(position),0)+1 FROM turns WHERE scope=?", (scope,)
|
|
345
|
+
).fetchone()[0]
|
|
346
|
+
)
|
|
347
|
+
db.execute(
|
|
348
|
+
"INSERT INTO turns VALUES(?,?,?,?,?,?,?) ON CONFLICT(scope,key) DO UPDATE SET "
|
|
349
|
+
"revision=excluded.revision,payload=excluded.payload,digest=excluded.digest,"
|
|
350
|
+
"expires=excluded.expires",
|
|
351
|
+
(
|
|
352
|
+
scope,
|
|
353
|
+
turn.turn_id,
|
|
354
|
+
position,
|
|
355
|
+
turn.revision,
|
|
356
|
+
payload,
|
|
357
|
+
digest(turn),
|
|
358
|
+
expires_at.timestamp() if expires_at else None,
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
self._changed(db, scope, turn.turn_id)
|
|
362
|
+
revision = self._active(db, scope)
|
|
363
|
+
db.execute(
|
|
364
|
+
"INSERT INTO operations VALUES(?,?,?,?)",
|
|
365
|
+
(scope, operation_id, operation_hash, revision),
|
|
366
|
+
)
|
|
367
|
+
return revision
|
|
368
|
+
|
|
369
|
+
def put_pin(self, pin, *, expected_revision):
|
|
370
|
+
integer_value("expected_revision", expected_revision)
|
|
371
|
+
if not isinstance(pin, Pin) or len(encode(pin).encode()) > 65536:
|
|
372
|
+
raise MemoryIntegrityError("Invalid or oversized persistent pin")
|
|
373
|
+
scope = self._scope(pin.scope)
|
|
374
|
+
with self._transaction() as db:
|
|
375
|
+
now = self._maintain(db)
|
|
376
|
+
self._active(db, scope, expected_revision)
|
|
377
|
+
self._not_deleted(db, scope, "pin", pin.key)
|
|
378
|
+
if pin.expires_at and pin.expires_at <= now:
|
|
379
|
+
raise MemoryIntegrityError("Cannot persist expired pin")
|
|
380
|
+
old = db.execute(
|
|
381
|
+
"SELECT revision FROM pins WHERE scope=? AND key=?", (scope, pin.key)
|
|
382
|
+
).fetchone()
|
|
383
|
+
if pin.revision != (old[0] + 1 if old else 1):
|
|
384
|
+
raise MemoryConflict("Pin revision conflict")
|
|
385
|
+
if (
|
|
386
|
+
old is None
|
|
387
|
+
and db.execute("SELECT count(*) FROM pins WHERE scope=?", (scope,)).fetchone()[0]
|
|
388
|
+
>= 256
|
|
389
|
+
):
|
|
390
|
+
raise MemoryIntegrityError("Pin limit exceeded")
|
|
391
|
+
if pin.source:
|
|
392
|
+
source_row = db.execute(
|
|
393
|
+
"SELECT * FROM turns WHERE scope=? AND key=?", (scope, pin.source.turn_id)
|
|
394
|
+
).fetchone()
|
|
395
|
+
if source_row is None:
|
|
396
|
+
raise MemoryConflict("Pin source no longer exists")
|
|
397
|
+
pin.source.extract(self._read("turn", source_row))
|
|
398
|
+
db.execute(
|
|
399
|
+
"INSERT INTO pins VALUES(?,?,?,?,?,?) ON CONFLICT(scope,key) DO UPDATE SET "
|
|
400
|
+
"revision=excluded.revision,payload=excluded.payload,digest=excluded.digest,"
|
|
401
|
+
"expires=excluded.expires",
|
|
402
|
+
(
|
|
403
|
+
scope,
|
|
404
|
+
pin.key,
|
|
405
|
+
pin.revision,
|
|
406
|
+
encode(pin),
|
|
407
|
+
digest(pin),
|
|
408
|
+
pin.expires_at.timestamp() if pin.expires_at else None,
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
self._changed(db, scope)
|
|
412
|
+
return self._active(db, scope)
|
|
413
|
+
|
|
414
|
+
@staticmethod
|
|
415
|
+
def _read(kind, row):
|
|
416
|
+
value = unpack(kind, row["payload"])
|
|
417
|
+
if digest(value) != row["digest"]:
|
|
418
|
+
raise MemoryIntegrityError("Memory payload digest mismatch")
|
|
419
|
+
if "scope" in row.keys() and encode(value.scope) != row["scope"]:
|
|
420
|
+
raise MemoryIntegrityError("Persisted memory scope mismatch")
|
|
421
|
+
if kind in ("turn", "pin") and (
|
|
422
|
+
(value.turn_id if kind == "turn" else value.key) != row["key"]
|
|
423
|
+
or value.revision != row["revision"]
|
|
424
|
+
):
|
|
425
|
+
raise MemoryIntegrityError("Persisted memory identity mismatch")
|
|
426
|
+
return value
|
|
427
|
+
|
|
428
|
+
def _snapshot(self, db, scope, now):
|
|
429
|
+
encoded = self._scope(scope)
|
|
430
|
+
revision = self._active(db, encoded)
|
|
431
|
+
turns = tuple(
|
|
432
|
+
self._read("turn", r)
|
|
433
|
+
for r in db.execute("SELECT * FROM turns WHERE scope=? ORDER BY position", (encoded,))
|
|
434
|
+
)
|
|
435
|
+
pins = KeyedPins(
|
|
436
|
+
scope,
|
|
437
|
+
tuple(
|
|
438
|
+
p
|
|
439
|
+
for r in db.execute("SELECT * FROM pins WHERE scope=? ORDER BY key", (encoded,))
|
|
440
|
+
if (p := self._read("pin", r)).is_active(now)
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
row = db.execute("SELECT * FROM summaries WHERE scope=?", (encoded,)).fetchone()
|
|
444
|
+
summary = self._read("summary", row) if row else None
|
|
445
|
+
if summary and not summary.matches(
|
|
446
|
+
tuple(t for t in turns if t.turn_id in summary.covered_turn_ids), scope, now
|
|
447
|
+
):
|
|
448
|
+
raise MemoryIntegrityError("Persisted summary lineage mismatch")
|
|
449
|
+
epoch = db.execute("SELECT epoch FROM meta").fetchone()[0]
|
|
450
|
+
identifier = digest(
|
|
451
|
+
[epoch, encoded, revision, [t.content_hash for t in turns], pins, summary]
|
|
452
|
+
)
|
|
453
|
+
return MemorySnapshot(scope, revision, identifier, turns, pins, summary, now)
|
|
454
|
+
|
|
455
|
+
def snapshot(self, scope):
|
|
456
|
+
with self._transaction() as db:
|
|
457
|
+
now = self._maintain(db)
|
|
458
|
+
return self._snapshot(db, scope, now)
|
|
459
|
+
|
|
460
|
+
def _current(self, db, snapshot, now):
|
|
461
|
+
if not isinstance(snapshot, MemorySnapshot):
|
|
462
|
+
raise MemoryIntegrityError("Expected a memory snapshot")
|
|
463
|
+
current = self._snapshot(db, snapshot.scope, now)
|
|
464
|
+
if current != snapshot and current.snapshot_id != snapshot.snapshot_id:
|
|
465
|
+
raise MemoryConflict("Memory snapshot is stale")
|
|
466
|
+
return current
|
|
467
|
+
|
|
468
|
+
def assert_current(self, snapshot):
|
|
469
|
+
with self._transaction() as db:
|
|
470
|
+
return self._current(db, snapshot, self._maintain(db))
|
|
471
|
+
|
|
472
|
+
def delete(self, scope, *, kind, key="", expected_revision):
|
|
473
|
+
integer_value("expected_revision", expected_revision)
|
|
474
|
+
if kind not in ("scope", "turn", "pin") or (kind == "scope" and key):
|
|
475
|
+
raise MemoryIntegrityError("Invalid deletion target")
|
|
476
|
+
if kind != "scope":
|
|
477
|
+
text_value("key", key)
|
|
478
|
+
encoded = self._scope(scope)
|
|
479
|
+
with self._transaction() as db:
|
|
480
|
+
self._maintain(db)
|
|
481
|
+
self._active(db, encoded, expected_revision)
|
|
482
|
+
self._delete(db, encoded, kind, key)
|
|
483
|
+
|
|
484
|
+
def put_summary(self, snapshot, summary):
|
|
485
|
+
if not isinstance(summary, SummarySnapshot) or len(encode(summary).encode()) > 256000:
|
|
486
|
+
raise MemoryIntegrityError("Invalid or oversized summary")
|
|
487
|
+
with self._transaction() as db:
|
|
488
|
+
current = self._current(db, snapshot, self._maintain(db))
|
|
489
|
+
history = tuple(t for t in current.history if t.turn_id in summary.covered_turn_ids)
|
|
490
|
+
if not summary.matches(history, current.scope, current.at):
|
|
491
|
+
raise MemoryConflict("Summary lineage or expiry does not match source")
|
|
492
|
+
encoded = self._scope(current.scope)
|
|
493
|
+
self._changed(db, encoded)
|
|
494
|
+
db.execute(
|
|
495
|
+
"INSERT INTO summaries VALUES(?,?,?,?)",
|
|
496
|
+
(
|
|
497
|
+
encoded,
|
|
498
|
+
encode(summary),
|
|
499
|
+
digest(summary),
|
|
500
|
+
summary.expires_at.timestamp() if summary.expires_at else None,
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def index_batch(self, snapshot, config=None, *, batch_size=32, cancellation=None):
|
|
505
|
+
config = RetrievalConfig() if config is None else config
|
|
506
|
+
integer_value("batch_size", batch_size, minimum=1)
|
|
507
|
+
validate_cancellation(cancellation)
|
|
508
|
+
raise_if_cancelled(cancellation)
|
|
509
|
+
if batch_size > 256 or not isinstance(config, RetrievalConfig):
|
|
510
|
+
raise MemoryIntegrityError("Invalid bounded indexing configuration")
|
|
511
|
+
profile = digest(config)
|
|
512
|
+
with self._transaction() as db:
|
|
513
|
+
current = self._current(db, snapshot, self._maintain(db))
|
|
514
|
+
scope = self._scope(current.scope)
|
|
515
|
+
# Keep one active profile per scope, rather than accumulating unbounded caches.
|
|
516
|
+
for table in ("coverage", "chunks"):
|
|
517
|
+
db.execute(f"DELETE FROM {table} WHERE scope=? AND profile<>?", (scope, profile))
|
|
518
|
+
completed = {
|
|
519
|
+
r["turn_id"]: r["source_hash"]
|
|
520
|
+
for r in db.execute(
|
|
521
|
+
"SELECT * FROM coverage WHERE scope=? AND profile=?", (scope, profile)
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
pending = [t for t in current.history if completed.get(t.turn_id) != t.content_hash]
|
|
525
|
+
changed = pending[:batch_size]
|
|
526
|
+
for turn in changed:
|
|
527
|
+
raise_if_cancelled(cancellation)
|
|
528
|
+
maximum = sum(
|
|
529
|
+
(len(m.content) + config.chunk_characters - config.overlap_characters - 1)
|
|
530
|
+
// (config.chunk_characters - config.overlap_characters)
|
|
531
|
+
for m in turn.messages
|
|
532
|
+
)
|
|
533
|
+
count = db.execute(
|
|
534
|
+
"SELECT count(*) FROM chunks WHERE scope=?", (scope,)
|
|
535
|
+
).fetchone()[0]
|
|
536
|
+
if count + maximum > MAX_CHUNKS:
|
|
537
|
+
raise MemoryIntegrityError("Persistent chunk index exceeds bound")
|
|
538
|
+
# Preserve the exact 0.8.0 call form when no token is supplied.
|
|
539
|
+
chunks = (
|
|
540
|
+
chunk_turns((turn,), config)
|
|
541
|
+
if cancellation is None
|
|
542
|
+
else chunk_turns((turn,), config, cancellation=cancellation)
|
|
543
|
+
)
|
|
544
|
+
db.execute("DELETE FROM chunks WHERE scope=? AND turn_id=?", (scope, turn.turn_id))
|
|
545
|
+
db.executemany(
|
|
546
|
+
"INSERT INTO chunks VALUES(?,?,?,?,?)",
|
|
547
|
+
[(scope, profile, turn.turn_id, c.chunk_id, encode(c)) for c in chunks],
|
|
548
|
+
)
|
|
549
|
+
db.execute(
|
|
550
|
+
"INSERT INTO coverage VALUES(?,?,?,?,?) ON CONFLICT(scope,profile,turn_id) "
|
|
551
|
+
"DO UPDATE SET source_hash=excluded.source_hash,"
|
|
552
|
+
"chunks_hash=excluded.chunks_hash",
|
|
553
|
+
(
|
|
554
|
+
scope,
|
|
555
|
+
profile,
|
|
556
|
+
turn.turn_id,
|
|
557
|
+
turn.content_hash,
|
|
558
|
+
digest(sorted((c.chunk_id, digest(c)) for c in chunks)),
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
raise_if_cancelled(cancellation)
|
|
562
|
+
return {
|
|
563
|
+
"status": "READY" if len(pending) <= batch_size else "BUILDING",
|
|
564
|
+
"processed": len(changed),
|
|
565
|
+
"remaining": max(0, len(pending) - batch_size),
|
|
566
|
+
"profile": profile,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
def chunk_index(self, snapshot, config=None):
|
|
570
|
+
config = RetrievalConfig() if config is None else config
|
|
571
|
+
if not isinstance(config, RetrievalConfig):
|
|
572
|
+
raise MemoryIntegrityError("Invalid indexing configuration")
|
|
573
|
+
profile = digest(config)
|
|
574
|
+
with self._transaction() as db:
|
|
575
|
+
current = self._current(db, snapshot, self._maintain(db))
|
|
576
|
+
scope = self._scope(current.scope)
|
|
577
|
+
chunks = []
|
|
578
|
+
for turn in current.history:
|
|
579
|
+
coverage = db.execute(
|
|
580
|
+
"SELECT * FROM coverage WHERE scope=? AND profile=? AND turn_id=?",
|
|
581
|
+
(scope, profile, turn.turn_id),
|
|
582
|
+
).fetchone()
|
|
583
|
+
if coverage is None or coverage["source_hash"] != turn.content_hash:
|
|
584
|
+
raise MemoryConflict("Chunk index is not ready for this snapshot")
|
|
585
|
+
values = tuple(
|
|
586
|
+
unpack("chunk", r[0])
|
|
587
|
+
for r in db.execute(
|
|
588
|
+
"SELECT payload FROM chunks WHERE scope=? AND profile=? "
|
|
589
|
+
"AND turn_id=? ORDER BY key",
|
|
590
|
+
(scope, profile, turn.turn_id),
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
if (
|
|
594
|
+
digest(sorted((c.chunk_id, digest(c)) for c in values))
|
|
595
|
+
!= coverage["chunks_hash"]
|
|
596
|
+
):
|
|
597
|
+
raise MemoryIntegrityError("Persistent chunk index is incomplete or corrupt")
|
|
598
|
+
for chunk in values:
|
|
599
|
+
chunk.validate_source(turn)
|
|
600
|
+
chunks.extend(values)
|
|
601
|
+
return ChunkIndex(history_digest(current.history), digest(config), tuple(chunks))
|
|
602
|
+
|
|
603
|
+
def cache_put(self, snapshot, key, payload, *, ttl_seconds=3600):
|
|
604
|
+
text_value("cache_key", key)
|
|
605
|
+
integer_value("ttl_seconds", ttl_seconds, minimum=1)
|
|
606
|
+
if (
|
|
607
|
+
ttl_seconds > 86400
|
|
608
|
+
or len(encode(payload).encode()) > 1_000_000
|
|
609
|
+
or not isinstance(payload, dict)
|
|
610
|
+
):
|
|
611
|
+
raise MemoryIntegrityError("Invalid bounded replay payload")
|
|
612
|
+
with self._transaction() as db:
|
|
613
|
+
current = self._current(db, snapshot, self._maintain(db))
|
|
614
|
+
scope = self._scope(current.scope)
|
|
615
|
+
if (
|
|
616
|
+
db.execute("SELECT count(*) FROM replay WHERE scope=?", (scope,)).fetchone()[0]
|
|
617
|
+
>= 100
|
|
618
|
+
):
|
|
619
|
+
raise MemoryIntegrityError("Replay cache entry limit reached")
|
|
620
|
+
db.execute(
|
|
621
|
+
"INSERT INTO replay VALUES(?,?,?,?,?,?) ON CONFLICT(scope,key) DO UPDATE SET "
|
|
622
|
+
"snapshot=excluded.snapshot,payload=excluded.payload,digest=excluded.digest,"
|
|
623
|
+
"expires=excluded.expires",
|
|
624
|
+
(
|
|
625
|
+
scope,
|
|
626
|
+
key,
|
|
627
|
+
current.snapshot_id,
|
|
628
|
+
encode(payload),
|
|
629
|
+
digest(payload),
|
|
630
|
+
current.at.timestamp() + ttl_seconds,
|
|
631
|
+
),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def cache_get(self, snapshot, key):
|
|
635
|
+
text_value("cache_key", key)
|
|
636
|
+
with self._transaction() as db:
|
|
637
|
+
current = self._current(db, snapshot, self._maintain(db))
|
|
638
|
+
row = db.execute(
|
|
639
|
+
"SELECT * FROM replay WHERE scope=? AND key=? AND snapshot=?",
|
|
640
|
+
(self._scope(current.scope), key, current.snapshot_id),
|
|
641
|
+
).fetchone()
|
|
642
|
+
if row is None:
|
|
643
|
+
return None
|
|
644
|
+
value = decode(row["payload"])
|
|
645
|
+
if digest(value) != row["digest"]:
|
|
646
|
+
raise MemoryIntegrityError("Replay payload is corrupt")
|
|
647
|
+
return value
|
|
648
|
+
|
|
649
|
+
def assemble(
|
|
650
|
+
self,
|
|
651
|
+
scope,
|
|
652
|
+
question,
|
|
653
|
+
budget,
|
|
654
|
+
*,
|
|
655
|
+
system,
|
|
656
|
+
retrieval_config=None,
|
|
657
|
+
cancellation=None,
|
|
658
|
+
summary_prefix=False,
|
|
659
|
+
**options,
|
|
660
|
+
):
|
|
661
|
+
"""Assemble the current snapshot. `summary_prefix=True` opts into rolling reuse."""
|
|
662
|
+
if type(summary_prefix) is not bool:
|
|
663
|
+
raise ContractError("summary_prefix must be an explicit boolean")
|
|
664
|
+
retrieval_config = RetrievalConfig() if retrieval_config is None else retrieval_config
|
|
665
|
+
validate_cancellation(cancellation)
|
|
666
|
+
raise_if_cancelled(cancellation)
|
|
667
|
+
snapshot = self.snapshot(scope)
|
|
668
|
+
while (
|
|
669
|
+
self.index_batch(snapshot, retrieval_config, cancellation=cancellation)["status"]
|
|
670
|
+
!= "READY"
|
|
671
|
+
):
|
|
672
|
+
raise_if_cancelled(cancellation)
|
|
673
|
+
result = assemble_context(
|
|
674
|
+
snapshot.history,
|
|
675
|
+
question,
|
|
676
|
+
snapshot.pins,
|
|
677
|
+
budget,
|
|
678
|
+
system=system,
|
|
679
|
+
at=snapshot.at,
|
|
680
|
+
retrieval_config=retrieval_config,
|
|
681
|
+
chunk_index=self.chunk_index(snapshot, retrieval_config),
|
|
682
|
+
summary_policy=(
|
|
683
|
+
None
|
|
684
|
+
if snapshot.summary is None
|
|
685
|
+
else (PrefixSummaryPolicy if summary_prefix else FrozenSummaryPolicy)(
|
|
686
|
+
snapshot.summary
|
|
687
|
+
)
|
|
688
|
+
),
|
|
689
|
+
cancellation=cancellation,
|
|
690
|
+
**options,
|
|
691
|
+
)
|
|
692
|
+
self.assert_current(snapshot)
|
|
693
|
+
raise_if_cancelled(cancellation)
|
|
694
|
+
return snapshot, result
|
|
695
|
+
|
|
696
|
+
def deletion_watermark(self):
|
|
697
|
+
with self._transaction() as db:
|
|
698
|
+
self._maintain(db)
|
|
699
|
+
row = db.execute("SELECT lineage,applied FROM meta").fetchone()
|
|
700
|
+
return {"lineage": row["lineage"], "minimum_deletion_seq": row["applied"]}
|
|
701
|
+
|
|
702
|
+
def prune(self):
|
|
703
|
+
"""Commit pending expiry/deletion cleanup even when no context is requested."""
|
|
704
|
+
return self.deletion_watermark()
|
|
705
|
+
|
|
706
|
+
@staticmethod
|
|
707
|
+
def _copy(source, destination):
|
|
708
|
+
source = private_file(source)
|
|
709
|
+
destination = private_file(destination, create=True, exclusive=True)
|
|
710
|
+
start = time.monotonic()
|
|
711
|
+
|
|
712
|
+
def progress(*_):
|
|
713
|
+
if time.monotonic() - start > 10:
|
|
714
|
+
raise StorageError("Backup deadline exceeded; partial destination is not usable")
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
with closing(connect(Path(source).absolute().as_uri() + "?mode=ro", uri=True)) as src:
|
|
718
|
+
with closing(connect(destination)) as dst:
|
|
719
|
+
src.backup(dst, pages=128, progress=progress, sleep=0.01)
|
|
720
|
+
except sqlite3.Error:
|
|
721
|
+
raise StorageError("Cannot copy validated memory database") from None
|
|
722
|
+
return destination
|
|
723
|
+
|
|
724
|
+
def backup(self, destination):
|
|
725
|
+
self.deletion_watermark()
|
|
726
|
+
return self._copy(self.path, destination)
|
|
727
|
+
|
|
728
|
+
@classmethod
|
|
729
|
+
def restore(cls, backup, destination, *, deletion_path, minimum_deletion_seq, clock=None):
|
|
730
|
+
integer_value("minimum_deletion_seq", minimum_deletion_seq)
|
|
731
|
+
private_file(backup)
|
|
732
|
+
private_file(deletion_path)
|
|
733
|
+
try:
|
|
734
|
+
with closing(
|
|
735
|
+
connect(Path(backup).absolute().as_uri() + "?mode=ro", uri=True)
|
|
736
|
+
) as source:
|
|
737
|
+
if source.execute("PRAGMA user_version").fetchone()[0] != 1:
|
|
738
|
+
raise MemoryIntegrityError("Unsupported backup schema")
|
|
739
|
+
records = source.execute("SELECT lineage,applied FROM meta").fetchall()
|
|
740
|
+
if len(records) != 1:
|
|
741
|
+
raise MemoryIntegrityError("Invalid backup metadata")
|
|
742
|
+
lineage, applied = records[0]
|
|
743
|
+
with closing(
|
|
744
|
+
connect(Path(deletion_path).absolute().as_uri() + "?mode=ro", uri=True)
|
|
745
|
+
) as authority:
|
|
746
|
+
known = authority.execute("SELECT lineage FROM authority").fetchall()
|
|
747
|
+
maximum, count = authority.execute(
|
|
748
|
+
"SELECT coalesce(max(seq),0),count(*) FROM events"
|
|
749
|
+
).fetchone()
|
|
750
|
+
if (
|
|
751
|
+
known != [(lineage,)]
|
|
752
|
+
or maximum != count
|
|
753
|
+
or maximum < max(applied, minimum_deletion_seq)
|
|
754
|
+
):
|
|
755
|
+
raise MemoryIntegrityError("Deletion authority is missing, mismatched or stale")
|
|
756
|
+
except (sqlite3.Error, ValueError, TypeError):
|
|
757
|
+
raise MemoryIntegrityError("Cannot validate recovery authority") from None
|
|
758
|
+
destination = cls._copy(backup, destination)
|
|
759
|
+
# Constructor refuses absent/wrong/degraded deletion authority before any public read.
|
|
760
|
+
restored = cls(destination, deletion_path=deletion_path, clock=clock)
|
|
761
|
+
if restored.deletion_watermark()["minimum_deletion_seq"] < minimum_deletion_seq:
|
|
762
|
+
raise MemoryIntegrityError(
|
|
763
|
+
"Deletion authority is older than the required recovery watermark"
|
|
764
|
+
)
|
|
765
|
+
with restored._transaction() as db:
|
|
766
|
+
db.execute("UPDATE meta SET epoch=?", (uuid.uuid4().hex,))
|
|
767
|
+
for table in ("chunks", "coverage", "summaries", "replay"):
|
|
768
|
+
db.execute(f"DELETE FROM {table}")
|
|
769
|
+
db.execute("UPDATE scopes SET revision=revision+1")
|
|
770
|
+
return restored
|