memdebug 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.
- memdebug/__init__.py +3 -0
- memdebug/__main__.py +3 -0
- memdebug/adapters/__init__.py +0 -0
- memdebug/adapters/base.py +37 -0
- memdebug/adapters/common.py +69 -0
- memdebug/adapters/folder.py +96 -0
- memdebug/adapters/markdown_git.py +731 -0
- memdebug/adapters/mem0.py +324 -0
- memdebug/adapters/openwebui.py +209 -0
- memdebug/adapters/restore.py +705 -0
- memdebug/agents.py +128 -0
- memdebug/backends.py +29 -0
- memdebug/cli.py +857 -0
- memdebug/demo.py +190 -0
- memdebug/describe.py +34 -0
- memdebug/diff.py +91 -0
- memdebug/docker_source.py +164 -0
- memdebug/errors.py +29 -0
- memdebug/hints.py +145 -0
- memdebug/ledger.py +700 -0
- memdebug/models.py +157 -0
- memdebug/monitor.py +246 -0
- memdebug/paths.py +19 -0
- memdebug/reconcile.py +83 -0
- memdebug/report.py +279 -0
- memdebug/rollback_flow.py +69 -0
- memdebug/selftest.py +482 -0
- memdebug/stores.py +276 -0
- memdebug/sync.py +177 -0
- memdebug/textsafe.py +73 -0
- memdebug/viewer/__init__.py +1 -0
- memdebug/viewer/html.py +136 -0
- memdebug/viewer/pages.py +569 -0
- memdebug/viewer/redline.py +242 -0
- memdebug/viewer/server.py +432 -0
- memdebug/viewer/style.py +218 -0
- memdebug/witness.py +199 -0
- memdebug-0.2.0.dist-info/METADATA +206 -0
- memdebug-0.2.0.dist-info/RECORD +43 -0
- memdebug-0.2.0.dist-info/WHEEL +4 -0
- memdebug-0.2.0.dist-info/entry_points.txt +2 -0
- memdebug-0.2.0.dist-info/licenses/LICENSE +202 -0
- memdebug-0.2.0.dist-info/licenses/NOTICE +4 -0
memdebug/ledger.py
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""Append-only, hash-chained event ledger stored in SQLite.
|
|
2
|
+
|
|
3
|
+
What the chain protects: edits to, or removal from the middle of, the ledger. Removing the
|
|
4
|
+
newest entries cannot be seen from the chain alone, so verify() accepts the head hash that a
|
|
5
|
+
snapshot recorded.
|
|
6
|
+
|
|
7
|
+
What it does not protect: someone who can rewrite the whole file can rebuild a valid chain.
|
|
8
|
+
Anchor the head hash somewhere else (a snapshot, a printed line) if that matters.
|
|
9
|
+
|
|
10
|
+
The file holds memory text, including text of deleted memories, so it is created with
|
|
11
|
+
owner-only permissions and by design cannot be edited or erased entry by entry.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sqlite3
|
|
21
|
+
import stat
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Iterator, Sequence
|
|
27
|
+
|
|
28
|
+
from pydantic import ValidationError
|
|
29
|
+
|
|
30
|
+
from .errors import LedgerConflictError, LedgerError, SnapshotError
|
|
31
|
+
from .models import (
|
|
32
|
+
META_OPS,
|
|
33
|
+
LedgerEntry,
|
|
34
|
+
Memory,
|
|
35
|
+
MemoryEvent,
|
|
36
|
+
Op,
|
|
37
|
+
Snapshot,
|
|
38
|
+
SnapshotInfo,
|
|
39
|
+
Trust,
|
|
40
|
+
derive_trust,
|
|
41
|
+
)
|
|
42
|
+
from .textsafe import has_unsafe_chars
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
GENESIS = "0" * 64
|
|
47
|
+
SCHEMA_VERSION = 3
|
|
48
|
+
|
|
49
|
+
MAX_SNAPSHOT_MEMORIES = 100_000
|
|
50
|
+
MAX_SNAPSHOT_CHARS = 100_000_000
|
|
51
|
+
MAX_LABEL_CHARS = 100
|
|
52
|
+
_SNAPSHOT_ID_RE = re.compile(r"^s([1-9][0-9]{0,8})\Z")
|
|
53
|
+
_EVENT_ID_RE = re.compile(r"^e([1-9][0-9]{0,11})\Z")
|
|
54
|
+
_IN_CHUNK = 500 # bound parameters per query (SQLite allows at least 999)
|
|
55
|
+
MAX_ROLLBACK_FILES = 200 # files named in a rollback record; the count of all of them is kept as well
|
|
56
|
+
_PATH_FORBIDDEN = frozenset('"\\:*?<>|') # the same names the markdown adapter refuses to read or write
|
|
57
|
+
_ROLLBACK_ACTIONS = ("restore", "recreate", "remove")
|
|
58
|
+
_ROLLBACK_SOURCES = ("git", "snapshot", "none")
|
|
59
|
+
_SHA_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
|
|
60
|
+
_BACKUP_REF_RE = re.compile(r"^refs/memdebug/backups/[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}\Z")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canonical(event: MemoryEvent) -> str:
|
|
64
|
+
return json.dumps(event.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compute_hash(prev_hash: str, event_id: str, payload: str) -> str:
|
|
68
|
+
return hashlib.sha256(f"{prev_hash}|{event_id}|{payload}".encode()).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_snapshot_id(value: object) -> int:
|
|
72
|
+
match = _SNAPSHOT_ID_RE.match(value) if isinstance(value, str) else None
|
|
73
|
+
if not match:
|
|
74
|
+
raise SnapshotError("a snapshot id looks like s1, s2, s3 ...")
|
|
75
|
+
return int(match.group(1))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _text_digest(text: str) -> str:
|
|
79
|
+
try:
|
|
80
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
81
|
+
except UnicodeEncodeError as exc:
|
|
82
|
+
raise SnapshotError("a memory contains text that is not valid Unicode") from exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _canonical_json(value: object) -> str:
|
|
86
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def snapshot_digest(
|
|
90
|
+
number: int, backend: str, scope: dict, taken_at: str, ledger_seq: int, ledger_head: str,
|
|
91
|
+
complete: bool, label: str | None, entries: list[list],
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Hash of everything that defines a snapshot. The ledger chain stores this value, so a
|
|
94
|
+
snapshot cannot be altered without the chain noticing."""
|
|
95
|
+
body = {
|
|
96
|
+
"id": number, "backend": backend, "scope": scope, "taken_at": taken_at,
|
|
97
|
+
"ledger_seq": ledger_seq, "ledger_head": ledger_head, "complete": bool(complete),
|
|
98
|
+
"label": label, "entries": sorted(entries, key=lambda e: e[0]),
|
|
99
|
+
}
|
|
100
|
+
return hashlib.sha256(_canonical_json(body).encode()).hexdigest()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_rollback_details(value: object) -> dict:
|
|
104
|
+
"""The facts a rollback record may hold, checked strictly: it is written by us, but read back from a file that
|
|
105
|
+
anyone could edit, so everything is verified again when the ledger is verified."""
|
|
106
|
+
if not isinstance(value, dict) or set(value) != {"target", "before_snapshot", "after_snapshot", "commit",
|
|
107
|
+
"previous_head", "backup", "file_count", "files"}:
|
|
108
|
+
raise ValueError("unexpected rollback fields")
|
|
109
|
+
for key in ("target", "before_snapshot", "after_snapshot"):
|
|
110
|
+
if not (value[key] is None and key != "target") and not (isinstance(value[key], str) and _SNAPSHOT_ID_RE.match(value[key])):
|
|
111
|
+
raise ValueError(f"{key} is not a snapshot id")
|
|
112
|
+
for key in ("commit", "previous_head"):
|
|
113
|
+
if value[key] is not None and not (isinstance(value[key], str) and _SHA_RE.match(value[key])):
|
|
114
|
+
raise ValueError(f"{key} is not a commit id")
|
|
115
|
+
if value["backup"] is not None and not (isinstance(value["backup"], str) and _BACKUP_REF_RE.match(value["backup"])):
|
|
116
|
+
raise ValueError("backup is not a memdebug backup reference")
|
|
117
|
+
files = value["files"]
|
|
118
|
+
if not (isinstance(value["file_count"], int) and not isinstance(value["file_count"], bool) and value["file_count"] >= 0):
|
|
119
|
+
raise ValueError("file_count is not a count")
|
|
120
|
+
if not isinstance(files, list) or len(files) > MAX_ROLLBACK_FILES or len(files) > value["file_count"]:
|
|
121
|
+
raise ValueError("files is not a short list")
|
|
122
|
+
for item in files:
|
|
123
|
+
if (not isinstance(item, dict) or set(item) != {"path", "action", "source"}
|
|
124
|
+
or not isinstance(item["path"], str) or not 0 < len(item["path"]) <= 256 or has_unsafe_chars(item["path"])
|
|
125
|
+
or any(ch in _PATH_FORBIDDEN for ch in item["path"]) or item["path"].startswith("/")
|
|
126
|
+
or ".." in item["path"].split("/") or item["action"] not in _ROLLBACK_ACTIONS
|
|
127
|
+
or item["source"] not in _ROLLBACK_SOURCES):
|
|
128
|
+
raise ValueError("a file entry is malformed")
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _snap_key(event: MemoryEvent) -> str | None:
|
|
133
|
+
"""Index key for snapshot bookkeeping events, kept apart from backend row ids so that no
|
|
134
|
+
backend can ever collide with it."""
|
|
135
|
+
if event.op not in META_OPS:
|
|
136
|
+
return None
|
|
137
|
+
return f"{event.op.value}:{event.memory_id.removeprefix('snapshot:')}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _decode(raw: bytes) -> str:
|
|
141
|
+
# Invalid UTF-8 in a stored value must not crash a read.
|
|
142
|
+
return raw.decode("utf-8", "replace")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _prepare_file(path: Path) -> None:
|
|
146
|
+
"""Create the file with 0600, or check the existing one. Refuses symlinks and non-files."""
|
|
147
|
+
try:
|
|
148
|
+
info = os.lstat(path)
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
if not path.parent.is_dir():
|
|
151
|
+
raise LedgerError(f"directory does not exist: {path.parent}") from None
|
|
152
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
153
|
+
try:
|
|
154
|
+
os.close(os.open(path, flags, 0o600))
|
|
155
|
+
except FileExistsError:
|
|
156
|
+
pass
|
|
157
|
+
except OSError as exc:
|
|
158
|
+
raise LedgerError(f"cannot create ledger file: {exc.strerror}") from exc
|
|
159
|
+
return
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
raise LedgerError(f"cannot access ledger file: {exc.strerror}") from exc
|
|
162
|
+
if stat.S_ISLNK(info.st_mode):
|
|
163
|
+
raise LedgerError("ledger path is a symlink; refusing to follow it")
|
|
164
|
+
if not stat.S_ISREG(info.st_mode):
|
|
165
|
+
raise LedgerError("ledger path is not a regular file")
|
|
166
|
+
if os.name == "posix" and info.st_mode & 0o077:
|
|
167
|
+
logger.warning(
|
|
168
|
+
"ledger file is readable by other users (mode %o); it holds memory text. "
|
|
169
|
+
"Run: chmod 600 on it.",
|
|
170
|
+
stat.S_IMODE(info.st_mode),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class VerifyResult:
|
|
176
|
+
ok: bool = True
|
|
177
|
+
problems: list[str] = field(default_factory=list)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Ledger:
|
|
181
|
+
def __init__(self, path: str | Path, *, readonly: bool = False, busy_timeout: float = 5.0):
|
|
182
|
+
self.path = Path(path)
|
|
183
|
+
self._readonly = readonly
|
|
184
|
+
self._busy_timeout = busy_timeout
|
|
185
|
+
if readonly:
|
|
186
|
+
self._open_readonly()
|
|
187
|
+
return
|
|
188
|
+
_prepare_file(self.path)
|
|
189
|
+
try:
|
|
190
|
+
self._db = sqlite3.connect(str(self.path), timeout=30, isolation_level=None)
|
|
191
|
+
self._db.text_factory = _decode
|
|
192
|
+
self._init_schema()
|
|
193
|
+
except sqlite3.Error as exc:
|
|
194
|
+
raise LedgerError(f"cannot open ledger: {exc}") from exc
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def open_readonly(cls, path: str | Path, *, busy_timeout: float = 5.0) -> "Ledger":
|
|
198
|
+
"""Open an existing ledger so that nothing can be written: the file is opened with SQLite's
|
|
199
|
+
read-only mode, never created and never upgraded. Used by the viewer."""
|
|
200
|
+
return cls(path, readonly=True, busy_timeout=busy_timeout)
|
|
201
|
+
|
|
202
|
+
def _open_readonly(self) -> None:
|
|
203
|
+
try:
|
|
204
|
+
info = os.lstat(self.path)
|
|
205
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
206
|
+
raise LedgerError("the ledger path is not a plain regular file")
|
|
207
|
+
uri = self.path.resolve(strict=True).as_uri() + "?mode=ro"
|
|
208
|
+
self._db = sqlite3.connect(uri, uri=True, timeout=self._busy_timeout, isolation_level=None)
|
|
209
|
+
self._db.text_factory = _decode
|
|
210
|
+
self._db.execute("PRAGMA query_only = ON")
|
|
211
|
+
version = self._db.execute("PRAGMA user_version").fetchone()[0]
|
|
212
|
+
tables = {r[0] for r in self._db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
213
|
+
except OSError as exc:
|
|
214
|
+
raise LedgerError(f"cannot access the ledger file: {exc.strerror}") from exc
|
|
215
|
+
except sqlite3.Error as exc:
|
|
216
|
+
raise LedgerError(f"cannot open the ledger read-only: {exc}") from exc
|
|
217
|
+
if "events" not in tables or version not in (1, 2, SCHEMA_VERSION):
|
|
218
|
+
self._db.close()
|
|
219
|
+
raise LedgerError("this file is not a memdebug ledger of a supported version")
|
|
220
|
+
if version != SCHEMA_VERSION:
|
|
221
|
+
self._db.close()
|
|
222
|
+
raise LedgerError(
|
|
223
|
+
"this ledger is from an older version; run any normal memdebug command once "
|
|
224
|
+
"(for example 'memdebug verify') to upgrade it, then try again"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _require_writable(self) -> None:
|
|
228
|
+
if self._readonly:
|
|
229
|
+
raise LedgerError("this ledger was opened read-only")
|
|
230
|
+
|
|
231
|
+
def close(self) -> None:
|
|
232
|
+
self._db.close()
|
|
233
|
+
|
|
234
|
+
# -- internals -------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
@contextmanager
|
|
237
|
+
def _tx(self) -> Iterator[None]:
|
|
238
|
+
self._db.execute("BEGIN IMMEDIATE")
|
|
239
|
+
try:
|
|
240
|
+
yield
|
|
241
|
+
except BaseException:
|
|
242
|
+
try:
|
|
243
|
+
self._db.execute("ROLLBACK")
|
|
244
|
+
except sqlite3.Error:
|
|
245
|
+
pass
|
|
246
|
+
raise
|
|
247
|
+
else:
|
|
248
|
+
self._db.execute("COMMIT")
|
|
249
|
+
|
|
250
|
+
def _init_schema(self) -> None:
|
|
251
|
+
version = self._db.execute("PRAGMA user_version").fetchone()[0]
|
|
252
|
+
tables = {r[0] for r in self._db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
253
|
+
if version == 0 and not tables:
|
|
254
|
+
with self._tx():
|
|
255
|
+
self._create_events_table()
|
|
256
|
+
self._create_snapshot_tables()
|
|
257
|
+
self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
258
|
+
elif version in (1, 2) and "events" in tables:
|
|
259
|
+
with self._tx(): # an older ledger is upgraded in place; its events and chain are untouched
|
|
260
|
+
if version == 1:
|
|
261
|
+
self._db.execute("ALTER TABLE events ADD COLUMN snap TEXT")
|
|
262
|
+
self._db.execute(
|
|
263
|
+
"CREATE UNIQUE INDEX idx_events_snap ON events(snap) WHERE snap IS NOT NULL"
|
|
264
|
+
)
|
|
265
|
+
self._create_snapshot_tables()
|
|
266
|
+
self._db.execute("ALTER TABLE events ADD COLUMN op TEXT")
|
|
267
|
+
self._db.execute("ALTER TABLE events ADD COLUMN trust TEXT")
|
|
268
|
+
for seq, payload in self._db.execute("SELECT seq, payload FROM events").fetchall():
|
|
269
|
+
try:
|
|
270
|
+
data = json.loads(payload)
|
|
271
|
+
values = (data.get("op"), data.get("trust"), seq)
|
|
272
|
+
except (ValueError, AttributeError):
|
|
273
|
+
continue # left empty; verify reports it
|
|
274
|
+
self._db.execute("UPDATE events SET op = ?, trust = ? WHERE seq = ?", values)
|
|
275
|
+
self._create_filter_indexes()
|
|
276
|
+
self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
277
|
+
elif version != SCHEMA_VERSION or "events" not in tables:
|
|
278
|
+
raise LedgerError("this file is not a memdebug ledger of a supported version")
|
|
279
|
+
|
|
280
|
+
def _create_events_table(self) -> None:
|
|
281
|
+
self._db.execute(
|
|
282
|
+
"CREATE TABLE events ("
|
|
283
|
+
"seq INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE, backend TEXT NOT NULL, "
|
|
284
|
+
"ref TEXT, snap TEXT, payload TEXT NOT NULL, prev_hash TEXT NOT NULL, hash TEXT NOT NULL, "
|
|
285
|
+
"op TEXT, trust TEXT)"
|
|
286
|
+
)
|
|
287
|
+
self._db.execute("CREATE UNIQUE INDEX idx_events_ref ON events(backend, ref) WHERE ref IS NOT NULL")
|
|
288
|
+
self._db.execute("CREATE UNIQUE INDEX idx_events_snap ON events(snap) WHERE snap IS NOT NULL")
|
|
289
|
+
self._create_filter_indexes()
|
|
290
|
+
|
|
291
|
+
def _create_filter_indexes(self) -> None:
|
|
292
|
+
self._db.execute("CREATE INDEX idx_events_op ON events(op, seq)")
|
|
293
|
+
self._db.execute("CREATE INDEX idx_events_trust ON events(trust, seq)")
|
|
294
|
+
|
|
295
|
+
def _create_snapshot_tables(self) -> None:
|
|
296
|
+
self._db.execute("CREATE TABLE blobs (hash TEXT PRIMARY KEY, text TEXT NOT NULL)")
|
|
297
|
+
self._db.execute(
|
|
298
|
+
"CREATE TABLE snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, backend TEXT NOT NULL, "
|
|
299
|
+
"scope TEXT NOT NULL, taken_at TEXT NOT NULL, ledger_seq INTEGER NOT NULL, "
|
|
300
|
+
"ledger_head TEXT NOT NULL, complete INTEGER NOT NULL, label TEXT, count INTEGER NOT NULL, "
|
|
301
|
+
"entries_hash TEXT NOT NULL)"
|
|
302
|
+
)
|
|
303
|
+
self._db.execute(
|
|
304
|
+
"CREATE TABLE snapshot_entries (snapshot_id INTEGER NOT NULL, memory_id TEXT NOT NULL, "
|
|
305
|
+
"hash TEXT NOT NULL, scope TEXT NOT NULL, PRIMARY KEY (snapshot_id, memory_id))"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# -- writing ---------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def _append_locked(self, events: Sequence[MemoryEvent]) -> list[LedgerEntry]:
|
|
311
|
+
"""Append inside a transaction the caller already holds."""
|
|
312
|
+
entries: list[LedgerEntry] = []
|
|
313
|
+
row = self._db.execute("SELECT seq, hash FROM events ORDER BY seq DESC LIMIT 1").fetchone()
|
|
314
|
+
seq = row[0] if row else 0
|
|
315
|
+
prev = row[1] if row else GENESIS
|
|
316
|
+
for event in events:
|
|
317
|
+
if event.trust == Trust.UNKNOWN and event.source is not None:
|
|
318
|
+
event = event.model_copy(update={"trust": derive_trust(event.source)})
|
|
319
|
+
seq += 1
|
|
320
|
+
event_id = f"e{seq}"
|
|
321
|
+
payload = canonical(event)
|
|
322
|
+
digest = compute_hash(prev, event_id, payload)
|
|
323
|
+
snap = _snap_key(event)
|
|
324
|
+
self._db.execute(
|
|
325
|
+
"INSERT INTO events (seq, id, backend, ref, snap, payload, prev_hash, hash, op, trust) "
|
|
326
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
327
|
+
(seq, event_id, event.backend, event.backend_ref, snap, payload, prev, digest,
|
|
328
|
+
event.op.value, event.trust.value),
|
|
329
|
+
)
|
|
330
|
+
entries.append(LedgerEntry(seq=seq, id=event_id, event=event, prev_hash=prev, hash=digest))
|
|
331
|
+
prev = digest
|
|
332
|
+
return entries
|
|
333
|
+
|
|
334
|
+
def append_many(self, events: Sequence[MemoryEvent]) -> list[LedgerEntry]:
|
|
335
|
+
"""Append events in one transaction: all of them, or none."""
|
|
336
|
+
self._require_writable()
|
|
337
|
+
if not events:
|
|
338
|
+
return []
|
|
339
|
+
if any(e.op in META_OPS for e in events):
|
|
340
|
+
raise LedgerError("snapshot bookkeeping events can only be written by the snapshot methods")
|
|
341
|
+
try:
|
|
342
|
+
with self._tx():
|
|
343
|
+
return self._append_locked(events)
|
|
344
|
+
except sqlite3.IntegrityError as exc:
|
|
345
|
+
raise LedgerConflictError("write collided with another writer; nothing was written") from exc
|
|
346
|
+
except sqlite3.Error as exc:
|
|
347
|
+
raise LedgerError(f"ledger write failed: {exc}") from exc
|
|
348
|
+
|
|
349
|
+
def append(self, event: MemoryEvent) -> LedgerEntry:
|
|
350
|
+
return self.append_many([event])[0]
|
|
351
|
+
|
|
352
|
+
# -- reading ---------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def head(self) -> str:
|
|
355
|
+
row = self._db.execute("SELECT hash FROM events ORDER BY seq DESC LIMIT 1").fetchone()
|
|
356
|
+
return row[0] if row else GENESIS
|
|
357
|
+
|
|
358
|
+
def backends(self) -> set[str]:
|
|
359
|
+
"""The store types that have events on record."""
|
|
360
|
+
return {row[0] for row in self._db.execute("SELECT DISTINCT backend FROM events") if row[0]}
|
|
361
|
+
|
|
362
|
+
def has_events(self, backend: str) -> bool:
|
|
363
|
+
row = self._db.execute("SELECT 1 FROM events WHERE backend = ? LIMIT 1", (backend,)).fetchone()
|
|
364
|
+
return row is not None
|
|
365
|
+
|
|
366
|
+
def known_refs(self, backend: str) -> set[str]:
|
|
367
|
+
rows = self._db.execute(
|
|
368
|
+
"SELECT ref FROM events WHERE backend = ? AND ref IS NOT NULL", (backend,)
|
|
369
|
+
).fetchall()
|
|
370
|
+
return {r[0] for r in rows}
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _entry_from_row(seq, event_id, payload, prev, digest) -> LedgerEntry:
|
|
374
|
+
try:
|
|
375
|
+
event = MemoryEvent.model_validate_json(payload)
|
|
376
|
+
except ValidationError as exc:
|
|
377
|
+
raise LedgerError(
|
|
378
|
+
f"entry seq {seq} is unreadable; run 'memdebug verify' to check the ledger"
|
|
379
|
+
) from exc
|
|
380
|
+
return LedgerEntry(seq=seq, id=event_id, event=event, prev_hash=prev, hash=digest)
|
|
381
|
+
|
|
382
|
+
def entries(self) -> list[LedgerEntry]:
|
|
383
|
+
rows = self._db.execute(
|
|
384
|
+
"SELECT seq, id, payload, prev_hash, hash FROM events ORDER BY seq"
|
|
385
|
+
).fetchall()
|
|
386
|
+
return [self._entry_from_row(*row) for row in rows]
|
|
387
|
+
|
|
388
|
+
def counts(self) -> dict:
|
|
389
|
+
"""Totals for an overview, from indexed columns (verify checks they match the events)."""
|
|
390
|
+
by_op = {op: n for op, n in self._db.execute("SELECT op, COUNT(*) FROM events GROUP BY op") if op}
|
|
391
|
+
return {
|
|
392
|
+
"events": sum(by_op.values()),
|
|
393
|
+
"by_op": by_op,
|
|
394
|
+
"untrusted": self._db.execute("SELECT COUNT(*) FROM events WHERE trust = 'untrusted'").fetchone()[0],
|
|
395
|
+
"snapshots": self._db.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0],
|
|
396
|
+
"head": self.head(),
|
|
397
|
+
"head_seq": self._db.execute("SELECT COALESCE(MAX(seq), 0) FROM events").fetchone()[0],
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
def events_page(
|
|
401
|
+
self, *, before_seq: int | None = None, limit: int = 50, op: str | None = None, trust: str | None = None
|
|
402
|
+
) -> list[LedgerEntry]:
|
|
403
|
+
"""Newest-first page of events. All filter values are checked against fixed lists and passed
|
|
404
|
+
as bound parameters."""
|
|
405
|
+
if not (isinstance(limit, int) and 1 <= limit <= 500):
|
|
406
|
+
raise LedgerError("limit must be between 1 and 500")
|
|
407
|
+
if before_seq is not None and not (isinstance(before_seq, int) and before_seq >= 1):
|
|
408
|
+
raise LedgerError("before_seq must be a positive integer")
|
|
409
|
+
if op is not None and op not in {o.value for o in Op}:
|
|
410
|
+
raise LedgerError("unknown event kind")
|
|
411
|
+
if trust is not None and trust not in {t.value for t in Trust}:
|
|
412
|
+
raise LedgerError("unknown trust value")
|
|
413
|
+
clauses: list[str] = []
|
|
414
|
+
params: list = []
|
|
415
|
+
for column, value in (("seq <", before_seq), ("op =", op), ("trust =", trust)):
|
|
416
|
+
if value is not None:
|
|
417
|
+
clauses.append(f"{column} ?") # column text comes from this fixed tuple only
|
|
418
|
+
params.append(value)
|
|
419
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
420
|
+
rows = self._db.execute(
|
|
421
|
+
f"SELECT seq, id, payload, prev_hash, hash FROM events{where} ORDER BY seq DESC LIMIT ?",
|
|
422
|
+
[*params, limit],
|
|
423
|
+
).fetchall()
|
|
424
|
+
return [self._entry_from_row(*row) for row in rows]
|
|
425
|
+
|
|
426
|
+
def get_entry(self, event_id: str) -> LedgerEntry | None:
|
|
427
|
+
if not (isinstance(event_id, str) and _EVENT_ID_RE.match(event_id)):
|
|
428
|
+
raise LedgerError("an event id looks like e1, e2, e3 ...")
|
|
429
|
+
row = self._db.execute(
|
|
430
|
+
"SELECT seq, id, payload, prev_hash, hash FROM events WHERE id = ?", (event_id,)
|
|
431
|
+
).fetchone()
|
|
432
|
+
return self._entry_from_row(*row) if row else None
|
|
433
|
+
|
|
434
|
+
def verify(self, expected_head: str | None = None) -> VerifyResult:
|
|
435
|
+
result = VerifyResult()
|
|
436
|
+
rows = self._db.execute(
|
|
437
|
+
"SELECT seq, id, backend, ref, snap, payload, prev_hash, hash, op, trust FROM events ORDER BY seq"
|
|
438
|
+
).fetchall()
|
|
439
|
+
expected_seq = 1
|
|
440
|
+
prev = GENESIS
|
|
441
|
+
hash_by_seq: dict[int, str] = {0: GENESIS}
|
|
442
|
+
recorded: dict[str, dict] = {} # snapshot id -> what the chain says about it
|
|
443
|
+
deleted: set[str] = set()
|
|
444
|
+
for seq, event_id, backend, ref, snap, payload, stored_prev, stored_hash, op_col, trust_col in rows:
|
|
445
|
+
if seq != expected_seq:
|
|
446
|
+
result.problems.append(f"entries missing before seq {seq}")
|
|
447
|
+
expected_seq = seq
|
|
448
|
+
if event_id != f"e{seq}":
|
|
449
|
+
result.problems.append(f"seq {seq} has a wrong id")
|
|
450
|
+
if stored_prev != prev:
|
|
451
|
+
result.problems.append(f"seq {seq} does not link to the entry before it")
|
|
452
|
+
if compute_hash(prev, event_id, payload) != stored_hash:
|
|
453
|
+
result.problems.append(f"seq {seq} was changed after it was written")
|
|
454
|
+
else:
|
|
455
|
+
self._check_event_content(result, seq, backend, ref, snap, payload, op_col, trust_col, recorded, deleted)
|
|
456
|
+
hash_by_seq[seq] = stored_hash
|
|
457
|
+
prev = stored_hash
|
|
458
|
+
expected_seq += 1
|
|
459
|
+
if expected_head is not None and prev != expected_head:
|
|
460
|
+
result.problems.append(
|
|
461
|
+
"ledger head differs from the expected head; newest entries may have been removed"
|
|
462
|
+
)
|
|
463
|
+
self._verify_snapshots(result, hash_by_seq, recorded, deleted)
|
|
464
|
+
result.ok = not result.problems
|
|
465
|
+
return result
|
|
466
|
+
|
|
467
|
+
@staticmethod
|
|
468
|
+
def _check_event_content(result, seq, backend, ref, snap, payload, op_col, trust_col, recorded, deleted) -> None:
|
|
469
|
+
try:
|
|
470
|
+
data = json.loads(payload)
|
|
471
|
+
op = data.get("op")
|
|
472
|
+
expected_snap = None
|
|
473
|
+
if op in (Op.SNAPSHOT.value, Op.SNAPSHOT_DELETED.value):
|
|
474
|
+
sid = str(data.get("memory_id", "")).removeprefix("snapshot:")
|
|
475
|
+
expected_snap = f"{op}:{sid}"
|
|
476
|
+
if op == Op.SNAPSHOT.value:
|
|
477
|
+
recorded[sid] = json.loads(data["after"])
|
|
478
|
+
else:
|
|
479
|
+
deleted.add(sid)
|
|
480
|
+
elif op == Op.ROLLBACK.value:
|
|
481
|
+
rid = str(data.get("memory_id", ""))
|
|
482
|
+
if not re.match(r"^rollback:r[1-9][0-9]{0,8}\Z", rid):
|
|
483
|
+
raise ValueError("bad rollback id")
|
|
484
|
+
expected_snap = f"{op}:{rid}"
|
|
485
|
+
validate_rollback_details(json.loads(data["after"]))
|
|
486
|
+
if (data.get("backend") != backend or data.get("backend_ref") != ref or snap != expected_snap
|
|
487
|
+
or data.get("op") != op_col or data.get("trust") != trust_col):
|
|
488
|
+
result.problems.append(f"seq {seq} index columns do not match its content")
|
|
489
|
+
except (ValueError, AttributeError, KeyError, TypeError):
|
|
490
|
+
result.problems.append(f"seq {seq} content is not readable")
|
|
491
|
+
|
|
492
|
+
def _verify_snapshots(self, result, hash_by_seq, recorded, deleted) -> None:
|
|
493
|
+
present: set[str] = set()
|
|
494
|
+
for row in self._db.execute("SELECT id FROM snapshots ORDER BY id").fetchall():
|
|
495
|
+
sid = f"s{row[0]}"
|
|
496
|
+
present.add(sid)
|
|
497
|
+
try:
|
|
498
|
+
info, digest = self._snapshot_from_db(row[0])
|
|
499
|
+
except SnapshotError as exc:
|
|
500
|
+
result.problems.append(str(exc))
|
|
501
|
+
continue
|
|
502
|
+
chain = recorded.get(sid)
|
|
503
|
+
if chain is None:
|
|
504
|
+
result.problems.append(f"snapshot {sid} is not recorded in the ledger chain")
|
|
505
|
+
elif not isinstance(chain, dict) or chain.get("hash") != digest:
|
|
506
|
+
result.problems.append(f"snapshot {sid} does not match its record in the ledger chain")
|
|
507
|
+
if sid in deleted:
|
|
508
|
+
result.problems.append(f"snapshot {sid} was deleted in the ledger but still exists")
|
|
509
|
+
if hash_by_seq.get(info.ledger_seq) != info.ledger_head:
|
|
510
|
+
result.problems.append(
|
|
511
|
+
f"snapshot {sid} refers to ledger entries that no longer match (removed or rewritten?)"
|
|
512
|
+
)
|
|
513
|
+
for sid in recorded:
|
|
514
|
+
if sid not in present and sid not in deleted:
|
|
515
|
+
result.problems.append(f"snapshot {sid} is recorded in the ledger but is missing")
|
|
516
|
+
for digest, text in self._db.execute("SELECT hash, text FROM blobs"):
|
|
517
|
+
try:
|
|
518
|
+
if _text_digest(text) != digest:
|
|
519
|
+
result.problems.append("a stored snapshot text does not match its hash")
|
|
520
|
+
except SnapshotError:
|
|
521
|
+
result.problems.append("a stored snapshot text is not valid text")
|
|
522
|
+
orphaned = self._db.execute(
|
|
523
|
+
"SELECT COUNT(*) FROM snapshot_entries WHERE hash NOT IN (SELECT hash FROM blobs)"
|
|
524
|
+
).fetchone()[0]
|
|
525
|
+
if orphaned:
|
|
526
|
+
result.problems.append(f"{orphaned} snapshot entries refer to texts that are missing")
|
|
527
|
+
|
|
528
|
+
# -- snapshots -----------------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def _snapshot_from_db(self, number: int) -> tuple[SnapshotInfo, str]:
|
|
531
|
+
"""Read a snapshot's header and recompute its digest from the stored entries."""
|
|
532
|
+
sid = f"s{number}"
|
|
533
|
+
row = self._db.execute(
|
|
534
|
+
"SELECT backend, scope, taken_at, ledger_seq, ledger_head, complete, label, count, entries_hash "
|
|
535
|
+
"FROM snapshots WHERE id = ?", (number,),
|
|
536
|
+
).fetchone()
|
|
537
|
+
if row is None:
|
|
538
|
+
raise SnapshotError(f"snapshot {sid} does not exist")
|
|
539
|
+
backend, scope_json, taken_at, ledger_seq, ledger_head, complete, label, count, stored = row
|
|
540
|
+
try:
|
|
541
|
+
scope = json.loads(scope_json)
|
|
542
|
+
entries = [
|
|
543
|
+
[mid, h, json.loads(sc)]
|
|
544
|
+
for mid, h, sc in self._db.execute(
|
|
545
|
+
"SELECT memory_id, hash, scope FROM snapshot_entries WHERE snapshot_id = ?", (number,)
|
|
546
|
+
)
|
|
547
|
+
]
|
|
548
|
+
info = SnapshotInfo(
|
|
549
|
+
id=sid, backend=backend, scope=scope, taken_at=taken_at, ledger_seq=ledger_seq,
|
|
550
|
+
ledger_head=ledger_head, complete=bool(complete), label=label, count=count,
|
|
551
|
+
)
|
|
552
|
+
except (ValueError, ValidationError, TypeError) as exc:
|
|
553
|
+
raise SnapshotError(f"snapshot {sid} is unreadable") from exc
|
|
554
|
+
digest = snapshot_digest(
|
|
555
|
+
number, backend, scope, taken_at, ledger_seq, ledger_head, bool(complete), label, entries
|
|
556
|
+
)
|
|
557
|
+
if digest != stored or count != len(entries):
|
|
558
|
+
raise SnapshotError(f"snapshot {sid} was changed after it was saved")
|
|
559
|
+
return info, digest
|
|
560
|
+
|
|
561
|
+
def save_snapshot(
|
|
562
|
+
self, backend: str, scope: dict[str, str], memories: Sequence[Memory], *, complete: bool,
|
|
563
|
+
taken_at: datetime, label: str | None = None,
|
|
564
|
+
) -> SnapshotInfo:
|
|
565
|
+
"""Store the memories as they are right now, and chain the snapshot's hash into the ledger."""
|
|
566
|
+
self._require_writable()
|
|
567
|
+
if label is not None and (not label or len(label) > MAX_LABEL_CHARS or has_unsafe_chars(label)):
|
|
568
|
+
raise SnapshotError(f"a label must be 1 to {MAX_LABEL_CHARS} printable characters")
|
|
569
|
+
if taken_at.tzinfo is None:
|
|
570
|
+
raise SnapshotError("the snapshot time must carry a timezone")
|
|
571
|
+
if len(memories) > MAX_SNAPSHOT_MEMORIES:
|
|
572
|
+
raise SnapshotError(f"too many memories for one snapshot (limit {MAX_SNAPSHOT_MEMORIES})")
|
|
573
|
+
if sum(len(m.text) for m in memories) > MAX_SNAPSHOT_CHARS:
|
|
574
|
+
raise SnapshotError("the memories are too large for one snapshot")
|
|
575
|
+
ids = [m.id for m in memories]
|
|
576
|
+
if len(set(ids)) != len(ids):
|
|
577
|
+
raise SnapshotError("the memory list contains the same id twice")
|
|
578
|
+
|
|
579
|
+
blobs: dict[str, str] = {}
|
|
580
|
+
entries: list[list] = []
|
|
581
|
+
for memory in sorted(memories, key=lambda m: m.id):
|
|
582
|
+
digest = _text_digest(memory.text)
|
|
583
|
+
blobs[digest] = memory.text
|
|
584
|
+
entries.append([memory.id, digest, dict(memory.scope)])
|
|
585
|
+
taken = taken_at.isoformat()
|
|
586
|
+
try:
|
|
587
|
+
with self._tx():
|
|
588
|
+
head = self._db.execute("SELECT seq, hash FROM events ORDER BY seq DESC LIMIT 1").fetchone()
|
|
589
|
+
ledger_seq, ledger_head = (head[0], head[1]) if head else (0, GENESIS)
|
|
590
|
+
self._db.executemany("INSERT OR IGNORE INTO blobs (hash, text) VALUES (?, ?)", list(blobs.items()))
|
|
591
|
+
cursor = self._db.execute(
|
|
592
|
+
"INSERT INTO snapshots (backend, scope, taken_at, ledger_seq, ledger_head, complete, label, "
|
|
593
|
+
"count, entries_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')",
|
|
594
|
+
(backend, _canonical_json(scope), taken, ledger_seq, ledger_head, int(complete), label,
|
|
595
|
+
len(entries)),
|
|
596
|
+
)
|
|
597
|
+
number = cursor.lastrowid
|
|
598
|
+
if number is None:
|
|
599
|
+
raise SnapshotError("could not save the snapshot: no row id was returned")
|
|
600
|
+
self._db.executemany(
|
|
601
|
+
"INSERT INTO snapshot_entries (snapshot_id, memory_id, hash, scope) VALUES (?, ?, ?, ?)",
|
|
602
|
+
[(number, mid, h, _canonical_json(sc)) for mid, h, sc in entries],
|
|
603
|
+
)
|
|
604
|
+
digest = snapshot_digest(number, backend, scope, taken, ledger_seq, ledger_head,
|
|
605
|
+
complete, label, entries)
|
|
606
|
+
self._db.execute("UPDATE snapshots SET entries_hash = ? WHERE id = ?", (digest, number))
|
|
607
|
+
sid = f"s{number}"
|
|
608
|
+
self._append_locked([MemoryEvent(
|
|
609
|
+
backend=backend, memory_id=f"snapshot:{sid}", op=Op.SNAPSHOT, ts=taken_at, scope=scope,
|
|
610
|
+
after=_canonical_json({"count": len(entries), "complete": bool(complete), "hash": digest}),
|
|
611
|
+
)])
|
|
612
|
+
info, _ = self._snapshot_from_db(number)
|
|
613
|
+
except sqlite3.IntegrityError as exc:
|
|
614
|
+
raise LedgerConflictError("write collided with another writer; nothing was written") from exc
|
|
615
|
+
except (sqlite3.Error, ValidationError) as exc:
|
|
616
|
+
raise SnapshotError(f"could not save the snapshot: {exc}") from exc
|
|
617
|
+
return info
|
|
618
|
+
|
|
619
|
+
def list_snapshots(self) -> list[SnapshotInfo]:
|
|
620
|
+
infos: list[SnapshotInfo] = []
|
|
621
|
+
for (number,) in self._db.execute("SELECT id FROM snapshots ORDER BY id").fetchall():
|
|
622
|
+
infos.append(self._snapshot_from_db(number)[0])
|
|
623
|
+
return infos
|
|
624
|
+
|
|
625
|
+
def load_snapshot(self, snapshot_id: str) -> Snapshot:
|
|
626
|
+
"""Load a snapshot after checking it against its own hash, its texts, and the ledger chain."""
|
|
627
|
+
number = parse_snapshot_id(snapshot_id)
|
|
628
|
+
info, digest = self._snapshot_from_db(number)
|
|
629
|
+
record = self._db.execute(
|
|
630
|
+
"SELECT payload FROM events WHERE snap = ?", (f"{Op.SNAPSHOT.value}:{info.id}",)
|
|
631
|
+
).fetchone()
|
|
632
|
+
try:
|
|
633
|
+
chained = json.loads(json.loads(record[0])["after"])["hash"] if record else None
|
|
634
|
+
except (ValueError, KeyError, TypeError):
|
|
635
|
+
chained = None
|
|
636
|
+
if chained != digest:
|
|
637
|
+
raise SnapshotError(f"snapshot {info.id} does not match its record in the ledger chain")
|
|
638
|
+
entries = self._db.execute(
|
|
639
|
+
"SELECT memory_id, hash, scope FROM snapshot_entries WHERE snapshot_id = ? ORDER BY memory_id",
|
|
640
|
+
(number,),
|
|
641
|
+
).fetchall()
|
|
642
|
+
texts: dict[str, str] = {}
|
|
643
|
+
wanted = sorted({h for _, h, _ in entries})
|
|
644
|
+
for i in range(0, len(wanted), _IN_CHUNK):
|
|
645
|
+
chunk = wanted[i:i + _IN_CHUNK]
|
|
646
|
+
marks = ",".join("?" for _ in chunk) # only placeholders are built; values are bound
|
|
647
|
+
for digest_, text in self._db.execute(f"SELECT hash, text FROM blobs WHERE hash IN ({marks})", chunk):
|
|
648
|
+
if _text_digest(text) != digest_:
|
|
649
|
+
raise SnapshotError(f"snapshot {info.id} contains a text that was changed")
|
|
650
|
+
texts[digest_] = text
|
|
651
|
+
try:
|
|
652
|
+
memories = [Memory(id=mid, text=texts[h], scope=json.loads(sc)) for mid, h, sc in entries]
|
|
653
|
+
except KeyError as exc:
|
|
654
|
+
raise SnapshotError(f"snapshot {info.id} is missing a stored text") from exc
|
|
655
|
+
except (ValueError, ValidationError) as exc:
|
|
656
|
+
raise SnapshotError(f"snapshot {info.id} contains an unreadable entry") from exc
|
|
657
|
+
return Snapshot(info=info, memories=memories)
|
|
658
|
+
|
|
659
|
+
def delete_snapshot(self, snapshot_id: str) -> SnapshotInfo:
|
|
660
|
+
"""Remove a snapshot. The deletion is chained into the ledger, so it cannot be done silently.
|
|
661
|
+
Texts no other snapshot uses are removed too."""
|
|
662
|
+
self._require_writable()
|
|
663
|
+
number = parse_snapshot_id(snapshot_id)
|
|
664
|
+
try:
|
|
665
|
+
with self._tx():
|
|
666
|
+
info, _ = self._snapshot_from_db(number)
|
|
667
|
+
self._db.execute("DELETE FROM snapshot_entries WHERE snapshot_id = ?", (number,))
|
|
668
|
+
self._db.execute("DELETE FROM snapshots WHERE id = ?", (number,))
|
|
669
|
+
self._db.execute("DELETE FROM blobs WHERE hash NOT IN (SELECT hash FROM snapshot_entries)")
|
|
670
|
+
self._append_locked([MemoryEvent(
|
|
671
|
+
backend=info.backend, memory_id=f"snapshot:{info.id}", op=Op.SNAPSHOT_DELETED,
|
|
672
|
+
ts=datetime.now(timezone.utc), scope=info.scope,
|
|
673
|
+
)])
|
|
674
|
+
except sqlite3.IntegrityError as exc:
|
|
675
|
+
raise LedgerConflictError("write collided with another writer; nothing was written") from exc
|
|
676
|
+
except sqlite3.Error as exc:
|
|
677
|
+
raise SnapshotError(f"could not delete the snapshot: {exc}") from exc
|
|
678
|
+
return info
|
|
679
|
+
|
|
680
|
+
def record_rollback(self, backend: str, scope: dict[str, str], details: dict, *, ts: datetime) -> LedgerEntry:
|
|
681
|
+
"""Chain a record of a completed rollback into the ledger. Only this method can write one, so a rollback
|
|
682
|
+
cannot be claimed (or hidden) by appending an ordinary event."""
|
|
683
|
+
self._require_writable()
|
|
684
|
+
if ts.tzinfo is None:
|
|
685
|
+
raise LedgerError("the rollback time must carry a timezone")
|
|
686
|
+
try:
|
|
687
|
+
clean = validate_rollback_details(details)
|
|
688
|
+
except ValueError as exc:
|
|
689
|
+
raise LedgerError(f"invalid rollback record: {exc}") from exc
|
|
690
|
+
try:
|
|
691
|
+
with self._tx():
|
|
692
|
+
number = self._db.execute("SELECT COUNT(*) FROM events WHERE op = ?", (Op.ROLLBACK.value,)).fetchone()[0] + 1
|
|
693
|
+
return self._append_locked([MemoryEvent(
|
|
694
|
+
backend=backend, memory_id=f"rollback:r{number}", op=Op.ROLLBACK, ts=ts, scope=scope,
|
|
695
|
+
after=_canonical_json(clean),
|
|
696
|
+
)])[0]
|
|
697
|
+
except sqlite3.IntegrityError as exc:
|
|
698
|
+
raise LedgerConflictError("write collided with another writer; nothing was written") from exc
|
|
699
|
+
except sqlite3.Error as exc:
|
|
700
|
+
raise LedgerError(f"could not record the rollback: {exc}") from exc
|