pi-durable-python 1.3.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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
"""Portable JSONL implementation of the storage contract, translated from v1.3.0.
|
|
2
|
+
|
|
3
|
+
Commits are append-only main markers plus document and live-task sidecars. In-memory state is
|
|
4
|
+
published only after the marker append succeeds; reopen replays confirmed records, truncates torn
|
|
5
|
+
or unconfirmed tails, and reclaims records that a later base or retirement made unnecessary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, NotRequired, TypedDict, TypeGuard, cast, overload
|
|
15
|
+
|
|
16
|
+
from ...context import Context
|
|
17
|
+
from ...env import Err, FileError, FileSystem
|
|
18
|
+
from ...errors import StorageRequestError
|
|
19
|
+
from ...ids import (
|
|
20
|
+
ConversationId,
|
|
21
|
+
DocumentId,
|
|
22
|
+
EntryId,
|
|
23
|
+
Seq,
|
|
24
|
+
SubmissionId,
|
|
25
|
+
TaskId,
|
|
26
|
+
id_from_number,
|
|
27
|
+
seq_from_number,
|
|
28
|
+
)
|
|
29
|
+
from ...types import (
|
|
30
|
+
ConversationQuery,
|
|
31
|
+
ConversationRecord,
|
|
32
|
+
Cursor,
|
|
33
|
+
DocumentAddress,
|
|
34
|
+
DocumentCreate,
|
|
35
|
+
DocumentPoint,
|
|
36
|
+
DocumentQuery,
|
|
37
|
+
DocumentRecord,
|
|
38
|
+
EntryLookup,
|
|
39
|
+
EntryQuery,
|
|
40
|
+
EntryRecord,
|
|
41
|
+
EntryWithHead,
|
|
42
|
+
Page,
|
|
43
|
+
StorageWrite,
|
|
44
|
+
StoredDocument,
|
|
45
|
+
SubmissionQuery,
|
|
46
|
+
SubmissionRecord,
|
|
47
|
+
TaskQuery,
|
|
48
|
+
TaskRecord,
|
|
49
|
+
)
|
|
50
|
+
from ..memory import MemoryStorage
|
|
51
|
+
|
|
52
|
+
FORMAT_VERSION = 1
|
|
53
|
+
MAIN_FILE = "main.jsonl"
|
|
54
|
+
RECLAIM_SUFFIX = ".reclaim"
|
|
55
|
+
|
|
56
|
+
_SIDECAR_NAME = re.compile(r"^(?:doc|task)-(?P<id>0|[1-9]\d*)\.jsonl$")
|
|
57
|
+
_RECLAIM_NAME = re.compile(r"^(?:doc|task)-(?:0|[1-9]\d*)\.jsonl\.reclaim$")
|
|
58
|
+
_MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
59
|
+
_MAIN_ONLY_WRITES = frozenset({"conversation", "entry", "submission", "document.retire"})
|
|
60
|
+
_MAIN_AND_TASK_WRITES = _MAIN_ONLY_WRITES | {"task"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class JsonlStorageOptions(TypedDict):
|
|
64
|
+
"""Options for opening a JSONL directory."""
|
|
65
|
+
|
|
66
|
+
fsync: NotRequired[bool]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class JsonlCorruptionError(Exception):
|
|
70
|
+
"""Committed JSONL state is inconsistent; opening the backend fails."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, message: str, cause: BaseException | None = None) -> None:
|
|
73
|
+
super().__init__(message)
|
|
74
|
+
self.name = "JsonlCorruptionError"
|
|
75
|
+
if cause is not None:
|
|
76
|
+
self.__cause__ = cause
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class JsonlStoragePoisonedError(Exception):
|
|
80
|
+
"""An append outcome is uncertain, so the open backend must be reopened."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, cause: BaseException) -> None:
|
|
83
|
+
super().__init__("JSONL storage is poisoned and must be reopened")
|
|
84
|
+
self.name = "JsonlStoragePoisonedError"
|
|
85
|
+
self.__cause__ = cause
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class _ParsedLine:
|
|
90
|
+
value: Any
|
|
91
|
+
start: int
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class _ParsedFile:
|
|
96
|
+
path: str
|
|
97
|
+
lines: list[_ParsedLine]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class _EncodedCommit:
|
|
102
|
+
marker: str
|
|
103
|
+
sidecars: dict[str, str]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_object(value: object) -> TypeGuard[dict[str, Any]]:
|
|
107
|
+
return isinstance(value, dict)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_safe_int(value: object) -> TypeGuard[int]:
|
|
111
|
+
return isinstance(value, int) and not isinstance(value, bool) and abs(value) <= _MAX_SAFE_INTEGER
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _sidecar_file_name(kind: str, id: int) -> str:
|
|
115
|
+
return f"{kind}-{id}.jsonl"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_sidecar_file_name(name: str) -> bool:
|
|
119
|
+
return _SIDECAR_NAME.match(name) is not None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _sidecar_file_id(name: str) -> int:
|
|
123
|
+
match = _SIDECAR_NAME.match(name)
|
|
124
|
+
assert match is not None
|
|
125
|
+
return int(match.group("id"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_reclaim_file_name(name: str) -> bool:
|
|
129
|
+
return _RECLAIM_NAME.match(name) is not None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_current_only(record: DocumentCreate | DocumentRecord) -> bool:
|
|
133
|
+
return record["scope"]["kind"] != "conversation" or record.get("history") == "latest"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _sidecar_key(file: str, seq: int, ordinal: int) -> str:
|
|
137
|
+
return json.dumps([file, seq, ordinal], separators=(",", ":"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _json_line(value: object) -> str:
|
|
141
|
+
return json.dumps(value, ensure_ascii=True, separators=(",", ":"), allow_nan=False) + "\n"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _error_from_file(action: str, error: FileError) -> RuntimeError:
|
|
145
|
+
wrapped = RuntimeError(f"JSONL {action} failed: {error}")
|
|
146
|
+
wrapped.__cause__ = error
|
|
147
|
+
return wrapped
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_json(text: str, description: str) -> object:
|
|
151
|
+
try:
|
|
152
|
+
return json.loads(text)
|
|
153
|
+
except json.JSONDecodeError as error:
|
|
154
|
+
raise JsonlCorruptionError(f"Malformed complete {description}", error) from error
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_main_operation(value: object, description: str) -> dict[str, Any]:
|
|
158
|
+
if not _is_object(value) or not isinstance(value.get("type"), str):
|
|
159
|
+
raise JsonlCorruptionError(f"Invalid write in {description}")
|
|
160
|
+
operation = value
|
|
161
|
+
kind = operation["type"]
|
|
162
|
+
if kind in ("conversation", "entry", "submission"):
|
|
163
|
+
record = operation.get("value")
|
|
164
|
+
if not _is_object(record) or not _is_safe_int(record.get("id")):
|
|
165
|
+
raise JsonlCorruptionError(f"Invalid {kind} write in {description}")
|
|
166
|
+
return operation
|
|
167
|
+
if kind == "task":
|
|
168
|
+
record = operation.get("value")
|
|
169
|
+
if (
|
|
170
|
+
not _is_object(record)
|
|
171
|
+
or not _is_safe_int(record.get("id"))
|
|
172
|
+
or not _is_object(record.get("state"))
|
|
173
|
+
or record["state"].get("status") != "terminal"
|
|
174
|
+
):
|
|
175
|
+
raise JsonlCorruptionError(f"Invalid terminal task write in {description}")
|
|
176
|
+
return operation
|
|
177
|
+
if kind == "document.retire":
|
|
178
|
+
if not _is_safe_int(operation.get("id")):
|
|
179
|
+
raise JsonlCorruptionError(f"Invalid document retirement in {description}")
|
|
180
|
+
return operation
|
|
181
|
+
if kind == "task.sidecar":
|
|
182
|
+
if (
|
|
183
|
+
not _is_safe_int(operation.get("id"))
|
|
184
|
+
or not _is_safe_int(operation.get("ordinal"))
|
|
185
|
+
or operation["ordinal"] < 0
|
|
186
|
+
):
|
|
187
|
+
raise JsonlCorruptionError(f"Invalid task sidecar write in {description}")
|
|
188
|
+
return operation
|
|
189
|
+
if kind == "document.create":
|
|
190
|
+
record = operation.get("record")
|
|
191
|
+
if (
|
|
192
|
+
not _is_object(record)
|
|
193
|
+
or not _is_safe_int(record.get("id"))
|
|
194
|
+
or not _is_safe_int(operation.get("ordinal"))
|
|
195
|
+
or operation["ordinal"] < 0
|
|
196
|
+
):
|
|
197
|
+
raise JsonlCorruptionError(f"Invalid document creation in {description}")
|
|
198
|
+
return operation
|
|
199
|
+
if kind == "document.change":
|
|
200
|
+
if (
|
|
201
|
+
not _is_safe_int(operation.get("id"))
|
|
202
|
+
or not _is_safe_int(operation.get("ordinal"))
|
|
203
|
+
or operation["ordinal"] < 0
|
|
204
|
+
):
|
|
205
|
+
raise JsonlCorruptionError(f"Invalid document change in {description}")
|
|
206
|
+
return operation
|
|
207
|
+
raise JsonlCorruptionError(f"Unknown write type in {description}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_main_marker(text: str, line: int) -> dict[str, Any]:
|
|
211
|
+
description = f"{MAIN_FILE} line {line}"
|
|
212
|
+
value = _parse_json(text, description)
|
|
213
|
+
if (
|
|
214
|
+
not _is_object(value)
|
|
215
|
+
or not _is_safe_int(value.get("format"))
|
|
216
|
+
or value["format"] != FORMAT_VERSION
|
|
217
|
+
or value.get("type") != "commit"
|
|
218
|
+
or not _is_safe_int(value.get("seq"))
|
|
219
|
+
or value["seq"] < 1
|
|
220
|
+
or not isinstance(value.get("writes"), list)
|
|
221
|
+
):
|
|
222
|
+
raise JsonlCorruptionError(f"Invalid commit marker in {description}")
|
|
223
|
+
return {
|
|
224
|
+
"format": FORMAT_VERSION,
|
|
225
|
+
"type": "commit",
|
|
226
|
+
"seq": seq_from_number(value["seq"]),
|
|
227
|
+
"writes": [_validate_main_operation(write, description) for write in value["writes"]],
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _validate_document_content(value: object, description: str) -> dict[str, Any]:
|
|
232
|
+
if not _is_object(value) or not _is_safe_int(value.get("version")) or value["version"] < 1:
|
|
233
|
+
raise JsonlCorruptionError(f"Invalid document content in {description}")
|
|
234
|
+
if value.get("kind") == "base" and _is_object(value.get("value")):
|
|
235
|
+
return value
|
|
236
|
+
if value.get("kind") == "delta" and isinstance(value.get("ops"), list):
|
|
237
|
+
return value
|
|
238
|
+
raise JsonlCorruptionError(f"Invalid document content in {description}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _parse_sidecar_record(text: str, file: str, line: int) -> dict[str, Any]:
|
|
242
|
+
description = f"{file} line {line}"
|
|
243
|
+
value = _parse_json(text, description)
|
|
244
|
+
if (
|
|
245
|
+
not _is_object(value)
|
|
246
|
+
or not _is_safe_int(value.get("format"))
|
|
247
|
+
or value["format"] != FORMAT_VERSION
|
|
248
|
+
or value.get("type") != "record"
|
|
249
|
+
or not _is_safe_int(value.get("seq"))
|
|
250
|
+
or value["seq"] < 1
|
|
251
|
+
or not _is_safe_int(value.get("ordinal"))
|
|
252
|
+
or value["ordinal"] < 0
|
|
253
|
+
or not _is_object(value.get("payload"))
|
|
254
|
+
or not isinstance(value["payload"].get("type"), str)
|
|
255
|
+
):
|
|
256
|
+
raise JsonlCorruptionError(f"Invalid sidecar record in {description}")
|
|
257
|
+
payload = value["payload"]
|
|
258
|
+
if payload["type"] == "task":
|
|
259
|
+
record = payload.get("value")
|
|
260
|
+
if (
|
|
261
|
+
not _is_object(record)
|
|
262
|
+
or not _is_safe_int(record.get("id"))
|
|
263
|
+
or not _is_object(record.get("state"))
|
|
264
|
+
or record["state"].get("status") == "terminal"
|
|
265
|
+
):
|
|
266
|
+
raise JsonlCorruptionError(f"Invalid live task record in {description}")
|
|
267
|
+
elif payload["type"] == "document":
|
|
268
|
+
if not _is_safe_int(payload.get("id")):
|
|
269
|
+
raise JsonlCorruptionError(f"Invalid document record in {description}")
|
|
270
|
+
_validate_document_content(payload.get("content"), description)
|
|
271
|
+
else:
|
|
272
|
+
raise JsonlCorruptionError(f"Unknown sidecar record type in {description}")
|
|
273
|
+
return value
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class JsonlStorage:
|
|
277
|
+
"""Portable JSONL implementation of the storage contract."""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
fs: FileSystem,
|
|
282
|
+
directory: str,
|
|
283
|
+
main_path: str,
|
|
284
|
+
options: JsonlStorageOptions | None,
|
|
285
|
+
) -> None:
|
|
286
|
+
self._fs = fs
|
|
287
|
+
self._directory = directory
|
|
288
|
+
self._main_path = main_path
|
|
289
|
+
self._fsync = bool(options and options.get("fsync", False))
|
|
290
|
+
self._memory = MemoryStorage()
|
|
291
|
+
self._current_only_documents: set[int] = set()
|
|
292
|
+
self._live_task_sidecars: set[int] = set()
|
|
293
|
+
self._closed = False
|
|
294
|
+
self._poison_error: JsonlStoragePoisonedError | None = None
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
async def open(
|
|
298
|
+
directory: str,
|
|
299
|
+
fs: FileSystem,
|
|
300
|
+
context: Context,
|
|
301
|
+
options: JsonlStorageOptions | None = None,
|
|
302
|
+
) -> JsonlStorage:
|
|
303
|
+
"""Open or create a JSONL storage directory using the supplied filesystem."""
|
|
304
|
+
|
|
305
|
+
absolute = await fs.absolute_path(directory, context)
|
|
306
|
+
if isinstance(absolute, Err):
|
|
307
|
+
raise _error_from_file("path resolution", absolute.error)
|
|
308
|
+
created = await fs.create_dir(absolute.value, {"recursive": True}, context)
|
|
309
|
+
if isinstance(created, Err):
|
|
310
|
+
raise _error_from_file("directory creation", created.error)
|
|
311
|
+
main_path = await fs.join_path([absolute.value, MAIN_FILE], context)
|
|
312
|
+
if isinstance(main_path, Err):
|
|
313
|
+
raise _error_from_file("path join", main_path.error)
|
|
314
|
+
storage = JsonlStorage(fs, absolute.value, main_path.value, options)
|
|
315
|
+
await storage._recover(context)
|
|
316
|
+
return storage
|
|
317
|
+
|
|
318
|
+
async def commit(self, writes: Sequence[StorageWrite], context: Context) -> Seq:
|
|
319
|
+
self._assert_usable()
|
|
320
|
+
prepared = self._memory.prepare_commit(writes)
|
|
321
|
+
encoded = self._encode_commit(prepared.seq, prepared.writes)
|
|
322
|
+
reclamations = self._plan_reclamations(prepared.writes, encoded)
|
|
323
|
+
sidecars: list[tuple[str, str, str]] = [
|
|
324
|
+
(file, content, await self._resolve_file(file, context))
|
|
325
|
+
for file, content in encoded.sidecars.items()
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
for file, content, path in sidecars:
|
|
329
|
+
result = await self._fs.append_file(path, content, context)
|
|
330
|
+
if isinstance(result, Err):
|
|
331
|
+
raise self._poison(_error_from_file(f"append to {file}", result.error))
|
|
332
|
+
if self._fsync:
|
|
333
|
+
for file, _content, path in sidecars:
|
|
334
|
+
result = await self._fs.flush_file(path, context)
|
|
335
|
+
if isinstance(result, Err):
|
|
336
|
+
raise self._poison(_error_from_file(f"flush of {file}", result.error))
|
|
337
|
+
marker = await self._fs.append_file(self._main_path, encoded.marker, context)
|
|
338
|
+
if isinstance(marker, Err):
|
|
339
|
+
raise self._poison(_error_from_file(f"append to {MAIN_FILE}", marker.error))
|
|
340
|
+
seq = prepared.apply()
|
|
341
|
+
self._adopt_sidecar_state(prepared.writes)
|
|
342
|
+
await self._reclaim_sidecars(reclamations, context)
|
|
343
|
+
return seq
|
|
344
|
+
|
|
345
|
+
async def mint_id(self) -> int:
|
|
346
|
+
return await self._store.mint_id()
|
|
347
|
+
|
|
348
|
+
async def conversation(self, id: ConversationId, context: Context) -> ConversationRecord | None:
|
|
349
|
+
return await self._store.conversation(id, context)
|
|
350
|
+
|
|
351
|
+
async def scan_conversations(
|
|
352
|
+
self,
|
|
353
|
+
query: ConversationQuery,
|
|
354
|
+
limit: int,
|
|
355
|
+
cursor: Cursor | None,
|
|
356
|
+
context: Context,
|
|
357
|
+
) -> Page[ConversationRecord, Cursor]:
|
|
358
|
+
return await self._store.scan_conversations(query, limit, cursor, context)
|
|
359
|
+
|
|
360
|
+
@overload
|
|
361
|
+
async def entry(self, id: EntryId, context: Context, /) -> EntryLookup | None: ...
|
|
362
|
+
|
|
363
|
+
@overload
|
|
364
|
+
async def entry(
|
|
365
|
+
self,
|
|
366
|
+
conversation_id: ConversationId,
|
|
367
|
+
id: EntryId,
|
|
368
|
+
context: Context,
|
|
369
|
+
/,
|
|
370
|
+
) -> EntryLookup | None: ...
|
|
371
|
+
|
|
372
|
+
async def entry(
|
|
373
|
+
self,
|
|
374
|
+
id_or_conversation_id: EntryId | ConversationId | int,
|
|
375
|
+
id_or_context: EntryId | Context | None = None,
|
|
376
|
+
context: Context | None = None,
|
|
377
|
+
) -> EntryLookup | None:
|
|
378
|
+
if context is None:
|
|
379
|
+
if not isinstance(id_or_context, Context) and id_or_context is not None:
|
|
380
|
+
raise StorageRequestError("Storage.entry() requires an entry ID")
|
|
381
|
+
return await self._store.entry(id_from_number(id_or_conversation_id), id_or_context)
|
|
382
|
+
if not _is_safe_int(id_or_context):
|
|
383
|
+
raise StorageRequestError("Storage.entry() requires an entry ID")
|
|
384
|
+
return await self._store.entry(
|
|
385
|
+
id_from_number(id_or_conversation_id),
|
|
386
|
+
id_from_number(id_or_context),
|
|
387
|
+
context,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
async def find_latest_head_marker(
|
|
391
|
+
self,
|
|
392
|
+
conversation_id: ConversationId,
|
|
393
|
+
at_or_before_entry_id: EntryId | None,
|
|
394
|
+
context: Context,
|
|
395
|
+
) -> EntryWithHead | None:
|
|
396
|
+
return await self._store.find_latest_head_marker(
|
|
397
|
+
conversation_id, at_or_before_entry_id, context
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
async def scan_entries(
|
|
401
|
+
self,
|
|
402
|
+
query: EntryQuery,
|
|
403
|
+
limit: int,
|
|
404
|
+
cursor: Cursor | None,
|
|
405
|
+
context: Context,
|
|
406
|
+
) -> Page[EntryRecord, Cursor]:
|
|
407
|
+
return await self._store.scan_entries(query, limit, cursor, context)
|
|
408
|
+
|
|
409
|
+
async def task(self, id: TaskId, context: Context) -> TaskRecord | None:
|
|
410
|
+
return await self._store.task(id, context)
|
|
411
|
+
|
|
412
|
+
async def scan_tasks(
|
|
413
|
+
self,
|
|
414
|
+
query: TaskQuery,
|
|
415
|
+
limit: int,
|
|
416
|
+
cursor: Cursor | None,
|
|
417
|
+
context: Context,
|
|
418
|
+
) -> Page[TaskRecord, Cursor]:
|
|
419
|
+
return await self._store.scan_tasks(query, limit, cursor, context)
|
|
420
|
+
|
|
421
|
+
async def submission(self, id: SubmissionId, context: Context) -> SubmissionRecord | None:
|
|
422
|
+
return await self._store.submission(id, context)
|
|
423
|
+
|
|
424
|
+
async def scan_submissions(
|
|
425
|
+
self,
|
|
426
|
+
query: SubmissionQuery,
|
|
427
|
+
limit: int,
|
|
428
|
+
cursor: Cursor | None,
|
|
429
|
+
context: Context,
|
|
430
|
+
) -> Page[SubmissionRecord, Cursor]:
|
|
431
|
+
return await self._store.scan_submissions(query, limit, cursor, context)
|
|
432
|
+
|
|
433
|
+
async def submission_by_request(
|
|
434
|
+
self,
|
|
435
|
+
conversation_id: ConversationId,
|
|
436
|
+
request_id: str,
|
|
437
|
+
context: Context,
|
|
438
|
+
) -> SubmissionRecord | None:
|
|
439
|
+
return await self._store.submission_by_request(conversation_id, request_id, context)
|
|
440
|
+
|
|
441
|
+
async def find_document(
|
|
442
|
+
self,
|
|
443
|
+
address: DocumentAddress,
|
|
444
|
+
at: DocumentPoint,
|
|
445
|
+
context: Context,
|
|
446
|
+
) -> DocumentRecord | None:
|
|
447
|
+
return await self._store.find_document(address, at, context)
|
|
448
|
+
|
|
449
|
+
async def document(
|
|
450
|
+
self,
|
|
451
|
+
id: DocumentId,
|
|
452
|
+
at: DocumentPoint,
|
|
453
|
+
context: Context,
|
|
454
|
+
) -> StoredDocument | None:
|
|
455
|
+
return await self._store.document(id, at, context)
|
|
456
|
+
|
|
457
|
+
async def scan_documents(
|
|
458
|
+
self,
|
|
459
|
+
query: DocumentQuery,
|
|
460
|
+
limit: int,
|
|
461
|
+
cursor: Cursor | None,
|
|
462
|
+
context: Context,
|
|
463
|
+
) -> Page[DocumentRecord, Cursor]:
|
|
464
|
+
return await self._store.scan_documents(query, limit, cursor, context)
|
|
465
|
+
|
|
466
|
+
async def close(self, context: Context) -> None:
|
|
467
|
+
if self._closed:
|
|
468
|
+
return
|
|
469
|
+
self._closed = True
|
|
470
|
+
await self._memory.close(context)
|
|
471
|
+
|
|
472
|
+
# ── Encoding and publication ────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
def _encode_commit(self, seq: Seq, writes: Sequence[StorageWrite]) -> _EncodedCommit:
|
|
475
|
+
main_writes: list[dict[str, Any]] = []
|
|
476
|
+
records: dict[str, list[dict[str, Any]]] = {}
|
|
477
|
+
next_ordinal = 0
|
|
478
|
+
|
|
479
|
+
def add_sidecar(file: str, payload: dict[str, Any]) -> int:
|
|
480
|
+
nonlocal next_ordinal
|
|
481
|
+
ordinal = next_ordinal
|
|
482
|
+
next_ordinal += 1
|
|
483
|
+
records.setdefault(file, []).append(
|
|
484
|
+
{
|
|
485
|
+
"format": FORMAT_VERSION,
|
|
486
|
+
"type": "record",
|
|
487
|
+
"seq": seq,
|
|
488
|
+
"ordinal": ordinal,
|
|
489
|
+
"payload": payload,
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
return ordinal
|
|
493
|
+
|
|
494
|
+
for write in writes:
|
|
495
|
+
kind = write["type"]
|
|
496
|
+
if kind in _MAIN_ONLY_WRITES:
|
|
497
|
+
main_writes.append(cast(dict[str, Any], write))
|
|
498
|
+
elif kind == "task":
|
|
499
|
+
task = cast(dict[str, Any], write["value"])
|
|
500
|
+
if task["state"]["status"] == "terminal":
|
|
501
|
+
main_writes.append(cast(dict[str, Any], write))
|
|
502
|
+
else:
|
|
503
|
+
ordinal = add_sidecar(
|
|
504
|
+
_sidecar_file_name("task", task["id"]),
|
|
505
|
+
{"type": "task", "value": task},
|
|
506
|
+
)
|
|
507
|
+
main_writes.append({"type": "task.sidecar", "id": task["id"], "ordinal": ordinal})
|
|
508
|
+
elif kind == "document.create":
|
|
509
|
+
record = cast(dict[str, Any], write["record"])
|
|
510
|
+
ordinal = add_sidecar(
|
|
511
|
+
_sidecar_file_name("doc", record["id"]),
|
|
512
|
+
{"type": "document", "id": record["id"], "content": write["content"]},
|
|
513
|
+
)
|
|
514
|
+
main_writes.append(
|
|
515
|
+
{"type": "document.create", "record": record, "ordinal": ordinal}
|
|
516
|
+
)
|
|
517
|
+
elif kind == "document.change":
|
|
518
|
+
id = cast(int, write["id"])
|
|
519
|
+
ordinal = add_sidecar(
|
|
520
|
+
_sidecar_file_name("doc", id),
|
|
521
|
+
{"type": "document", "id": id, "content": write["content"]},
|
|
522
|
+
)
|
|
523
|
+
main_writes.append({"type": "document.change", "id": id, "ordinal": ordinal})
|
|
524
|
+
|
|
525
|
+
sidecars = {
|
|
526
|
+
file: "".join(_json_line(record) for record in file_records)
|
|
527
|
+
for file, file_records in records.items()
|
|
528
|
+
}
|
|
529
|
+
marker = _json_line(
|
|
530
|
+
{"format": FORMAT_VERSION, "type": "commit", "seq": seq, "writes": main_writes}
|
|
531
|
+
)
|
|
532
|
+
return _EncodedCommit(marker=marker, sidecars=sidecars)
|
|
533
|
+
|
|
534
|
+
def _plan_reclamations(
|
|
535
|
+
self,
|
|
536
|
+
writes: Sequence[StorageWrite],
|
|
537
|
+
encoded: _EncodedCommit,
|
|
538
|
+
) -> dict[str, str]:
|
|
539
|
+
created_current_only: dict[int, None] = {}
|
|
540
|
+
retired_documents: dict[int, None] = {}
|
|
541
|
+
base_documents: dict[int, None] = {}
|
|
542
|
+
final_tasks: dict[int, dict[str, Any]] = {}
|
|
543
|
+
for write in writes:
|
|
544
|
+
kind = write["type"]
|
|
545
|
+
if kind == "document.create":
|
|
546
|
+
record = cast(dict[str, Any], write["record"])
|
|
547
|
+
if _is_current_only(cast(DocumentCreate, record)):
|
|
548
|
+
created_current_only[record["id"]] = None
|
|
549
|
+
elif kind == "document.change":
|
|
550
|
+
if write["content"]["kind"] == "base":
|
|
551
|
+
base_documents[cast(int, write["id"])] = None
|
|
552
|
+
elif kind == "document.retire":
|
|
553
|
+
retired_documents[cast(int, write["id"])] = None
|
|
554
|
+
elif kind == "task":
|
|
555
|
+
task = cast(dict[str, Any], write["value"])
|
|
556
|
+
final_tasks[task["id"]] = task
|
|
557
|
+
|
|
558
|
+
replacements: dict[str, str] = {}
|
|
559
|
+
|
|
560
|
+
def is_current_only_document(id: int) -> bool:
|
|
561
|
+
return id in self._current_only_documents or id in created_current_only
|
|
562
|
+
|
|
563
|
+
for id in retired_documents:
|
|
564
|
+
if is_current_only_document(id):
|
|
565
|
+
replacements[_sidecar_file_name("doc", id)] = ""
|
|
566
|
+
for id in base_documents:
|
|
567
|
+
if not is_current_only_document(id) or id in retired_documents:
|
|
568
|
+
continue
|
|
569
|
+
file = _sidecar_file_name("doc", id)
|
|
570
|
+
content = encoded.sidecars.get(file)
|
|
571
|
+
if content is not None:
|
|
572
|
+
replacements[file] = content
|
|
573
|
+
for id, task in final_tasks.items():
|
|
574
|
+
if task["state"]["status"] == "terminal" and (
|
|
575
|
+
id in self._live_task_sidecars
|
|
576
|
+
or _sidecar_file_name("task", id) in encoded.sidecars
|
|
577
|
+
):
|
|
578
|
+
replacements[_sidecar_file_name("task", id)] = ""
|
|
579
|
+
return replacements
|
|
580
|
+
|
|
581
|
+
def _adopt_sidecar_state(self, writes: Sequence[StorageWrite]) -> None:
|
|
582
|
+
for write in writes:
|
|
583
|
+
if write["type"] == "document.create":
|
|
584
|
+
record = cast(dict[str, Any], write["record"])
|
|
585
|
+
if _is_current_only(cast(DocumentCreate, record)):
|
|
586
|
+
self._current_only_documents.add(record["id"])
|
|
587
|
+
elif write["type"] == "task":
|
|
588
|
+
task = cast(dict[str, Any], write["value"])
|
|
589
|
+
if task["state"]["status"] == "terminal":
|
|
590
|
+
self._live_task_sidecars.discard(task["id"])
|
|
591
|
+
else:
|
|
592
|
+
self._live_task_sidecars.add(task["id"])
|
|
593
|
+
|
|
594
|
+
async def _reclaim_sidecars(self, replacements: dict[str, str], context: Context) -> None:
|
|
595
|
+
"""The marker already published this state, so reclamation is retryable maintenance."""
|
|
596
|
+
|
|
597
|
+
if not replacements:
|
|
598
|
+
return
|
|
599
|
+
if self._fsync:
|
|
600
|
+
flushed = await self._fs.flush_file(self._main_path, context)
|
|
601
|
+
if isinstance(flushed, Err):
|
|
602
|
+
return
|
|
603
|
+
for file, content in replacements.items():
|
|
604
|
+
await self._replace_sidecar(file, content, context)
|
|
605
|
+
|
|
606
|
+
async def _replace_sidecar(self, file: str, content: str, context: Context) -> None:
|
|
607
|
+
path = await self._try_join([self._directory, file], context)
|
|
608
|
+
if path is None:
|
|
609
|
+
return
|
|
610
|
+
if content == "":
|
|
611
|
+
await self._fs.remove(path, {"force": True}, context)
|
|
612
|
+
return
|
|
613
|
+
temporary_path = await self._try_join([self._directory, f"{file}{RECLAIM_SUFFIX}"], context)
|
|
614
|
+
if temporary_path is None:
|
|
615
|
+
return
|
|
616
|
+
written = await self._fs.write_file(temporary_path, content, context)
|
|
617
|
+
if isinstance(written, Err):
|
|
618
|
+
return
|
|
619
|
+
if self._fsync:
|
|
620
|
+
flushed = await self._fs.flush_file(temporary_path, context)
|
|
621
|
+
if isinstance(flushed, Err):
|
|
622
|
+
return
|
|
623
|
+
await self._fs.rename_file(temporary_path, path, context)
|
|
624
|
+
|
|
625
|
+
# ── Recovery ────────────────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
async def _recover(self, context: Context) -> None:
|
|
628
|
+
main = await self._read_lines(
|
|
629
|
+
self._main_path,
|
|
630
|
+
MAIN_FILE,
|
|
631
|
+
context,
|
|
632
|
+
lambda text, line: _parse_main_marker(text, line),
|
|
633
|
+
)
|
|
634
|
+
previous_seq = 0
|
|
635
|
+
for marker in main.lines:
|
|
636
|
+
if marker.value["seq"] <= previous_seq:
|
|
637
|
+
raise JsonlCorruptionError(
|
|
638
|
+
f"Commit sequence does not strictly increase in {MAIN_FILE}"
|
|
639
|
+
)
|
|
640
|
+
previous_seq = marker.value["seq"]
|
|
641
|
+
|
|
642
|
+
listed = await self._fs.list_dir(self._directory, context)
|
|
643
|
+
if isinstance(listed, Err):
|
|
644
|
+
raise _error_from_file("directory listing", listed.error)
|
|
645
|
+
for info in listed.value:
|
|
646
|
+
if info["kind"] == "file" and _is_reclaim_file_name(info["name"]):
|
|
647
|
+
await self._fs.remove(info["path"], {"force": True}, context)
|
|
648
|
+
sidecar_files = sorted(
|
|
649
|
+
info["name"]
|
|
650
|
+
for info in listed.value
|
|
651
|
+
if info["kind"] == "file" and _is_sidecar_file_name(info["name"])
|
|
652
|
+
)
|
|
653
|
+
parsed_files: dict[str, _ParsedFile] = {}
|
|
654
|
+
record_by_key: dict[str, _ParsedLine] = {}
|
|
655
|
+
for file in sidecar_files:
|
|
656
|
+
path = await self._try_join([self._directory, file], context)
|
|
657
|
+
if path is None:
|
|
658
|
+
raise _error_from_file("path join", FileError("invalid", "Invalid path", file))
|
|
659
|
+
parsed = await self._read_lines(
|
|
660
|
+
path,
|
|
661
|
+
file,
|
|
662
|
+
context,
|
|
663
|
+
lambda text, line, file=file: _parse_sidecar_record(text, file, line),
|
|
664
|
+
)
|
|
665
|
+
parsed_files[file] = parsed
|
|
666
|
+
previous: dict[str, Any] | None = None
|
|
667
|
+
for line in parsed.lines:
|
|
668
|
+
value = line.value
|
|
669
|
+
if previous is not None and (
|
|
670
|
+
value["seq"] < previous["seq"]
|
|
671
|
+
or (value["seq"] == previous["seq"] and value["ordinal"] <= previous["ordinal"])
|
|
672
|
+
):
|
|
673
|
+
raise JsonlCorruptionError(f"Sidecar records are out of order in {file}")
|
|
674
|
+
previous = value
|
|
675
|
+
record_by_key[_sidecar_key(file, value["seq"], value["ordinal"])] = line
|
|
676
|
+
|
|
677
|
+
current_only_documents: dict[int, None] = {}
|
|
678
|
+
retired_documents: dict[int, None] = {}
|
|
679
|
+
final_task_is_live: dict[int, bool] = {}
|
|
680
|
+
for marker_line in main.lines:
|
|
681
|
+
for operation in marker_line.value["writes"]:
|
|
682
|
+
kind = operation["type"]
|
|
683
|
+
if kind == "document.create":
|
|
684
|
+
if _is_current_only(operation["record"]):
|
|
685
|
+
current_only_documents[operation["record"]["id"]] = None
|
|
686
|
+
elif kind == "document.retire":
|
|
687
|
+
retired_documents[operation["id"]] = None
|
|
688
|
+
elif kind == "task":
|
|
689
|
+
final_task_is_live[operation["value"]["id"]] = False
|
|
690
|
+
elif kind == "task.sidecar":
|
|
691
|
+
final_task_is_live[operation["id"]] = True
|
|
692
|
+
retired_current_only = {
|
|
693
|
+
id: None for id in retired_documents if id in current_only_documents
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
latest_bases: dict[int, dict[str, Any]] = {}
|
|
697
|
+
for marker_line in main.lines:
|
|
698
|
+
marker = marker_line.value
|
|
699
|
+
for operation in marker["writes"]:
|
|
700
|
+
kind = operation["type"]
|
|
701
|
+
if kind not in ("document.create", "document.change"):
|
|
702
|
+
continue
|
|
703
|
+
id = operation["record"]["id"] if kind == "document.create" else operation["id"]
|
|
704
|
+
if id not in current_only_documents:
|
|
705
|
+
continue
|
|
706
|
+
record_line = record_by_key.get(
|
|
707
|
+
_sidecar_key(_sidecar_file_name("doc", id), marker["seq"], operation["ordinal"])
|
|
708
|
+
)
|
|
709
|
+
if record_line is None:
|
|
710
|
+
continue
|
|
711
|
+
record = record_line.value
|
|
712
|
+
if (
|
|
713
|
+
record["payload"]["type"] != "document"
|
|
714
|
+
or record["payload"]["id"] != id
|
|
715
|
+
or record["payload"]["content"]["kind"] != "base"
|
|
716
|
+
):
|
|
717
|
+
continue
|
|
718
|
+
previous = latest_bases.get(id)
|
|
719
|
+
if (
|
|
720
|
+
previous is None
|
|
721
|
+
or record["seq"] > previous["seq"]
|
|
722
|
+
or (record["seq"] == previous["seq"] and record["ordinal"] > previous["ordinal"])
|
|
723
|
+
):
|
|
724
|
+
latest_bases[id] = record
|
|
725
|
+
|
|
726
|
+
terminal_tasks = {id for id, live in final_task_is_live.items() if not live}
|
|
727
|
+
confirmed: set[str] = set()
|
|
728
|
+
for marker_line in main.lines:
|
|
729
|
+
marker = marker_line.value
|
|
730
|
+
writes: list[dict[str, Any]] = []
|
|
731
|
+
for operation in marker["writes"]:
|
|
732
|
+
kind = operation["type"]
|
|
733
|
+
if kind in _MAIN_AND_TASK_WRITES:
|
|
734
|
+
writes.append(operation)
|
|
735
|
+
elif kind == "task.sidecar":
|
|
736
|
+
optional = operation["id"] in terminal_tasks
|
|
737
|
+
record = self._confirm_record(
|
|
738
|
+
marker,
|
|
739
|
+
operation["ordinal"],
|
|
740
|
+
_sidecar_file_name("task", operation["id"]),
|
|
741
|
+
record_by_key,
|
|
742
|
+
confirmed,
|
|
743
|
+
optional,
|
|
744
|
+
)
|
|
745
|
+
if record is not None:
|
|
746
|
+
if (
|
|
747
|
+
record["payload"]["type"] != "task"
|
|
748
|
+
or record["payload"]["value"]["id"] != operation["id"]
|
|
749
|
+
):
|
|
750
|
+
raise JsonlCorruptionError(
|
|
751
|
+
f"Confirmed task sidecar data does not match commit {marker['seq']}"
|
|
752
|
+
)
|
|
753
|
+
if not optional:
|
|
754
|
+
writes.append({"type": "task", "value": record["payload"]["value"]})
|
|
755
|
+
else:
|
|
756
|
+
id = (
|
|
757
|
+
operation["record"]["id"]
|
|
758
|
+
if kind == "document.create"
|
|
759
|
+
else operation["id"]
|
|
760
|
+
)
|
|
761
|
+
reclaimed = id in retired_current_only or self._is_before_latest_base(
|
|
762
|
+
latest_bases, id, marker["seq"], operation["ordinal"]
|
|
763
|
+
)
|
|
764
|
+
record = self._confirm_record(
|
|
765
|
+
marker,
|
|
766
|
+
operation["ordinal"],
|
|
767
|
+
_sidecar_file_name("doc", id),
|
|
768
|
+
record_by_key,
|
|
769
|
+
confirmed,
|
|
770
|
+
reclaimed,
|
|
771
|
+
)
|
|
772
|
+
content: dict[str, Any] | None = None
|
|
773
|
+
if record is not None:
|
|
774
|
+
if (
|
|
775
|
+
record["payload"]["type"] != "document"
|
|
776
|
+
or record["payload"]["id"] != id
|
|
777
|
+
):
|
|
778
|
+
raise JsonlCorruptionError(
|
|
779
|
+
f"Confirmed document sidecar data does not match commit {marker['seq']}"
|
|
780
|
+
)
|
|
781
|
+
content = record["payload"]["content"]
|
|
782
|
+
if kind == "document.create":
|
|
783
|
+
if content is not None and content["kind"] != "base":
|
|
784
|
+
raise JsonlCorruptionError(
|
|
785
|
+
f"Document creation lacks a confirmed base in commit {marker['seq']}"
|
|
786
|
+
)
|
|
787
|
+
writes.append(
|
|
788
|
+
{
|
|
789
|
+
"type": "document.create",
|
|
790
|
+
"record": operation["record"],
|
|
791
|
+
"content": (
|
|
792
|
+
{"kind": "base", "version": 1, "value": {}}
|
|
793
|
+
if reclaimed or content is None
|
|
794
|
+
else content
|
|
795
|
+
),
|
|
796
|
+
}
|
|
797
|
+
)
|
|
798
|
+
elif not reclaimed and content is not None:
|
|
799
|
+
writes.append(
|
|
800
|
+
{"type": "document.change", "id": id, "content": content}
|
|
801
|
+
)
|
|
802
|
+
try:
|
|
803
|
+
self._memory.prepare_commit(cast(list[StorageWrite], writes), marker["seq"]).apply()
|
|
804
|
+
except Exception as error:
|
|
805
|
+
raise JsonlCorruptionError(
|
|
806
|
+
f"Invalid committed state at sequence {marker['seq']}", error
|
|
807
|
+
) from error
|
|
808
|
+
|
|
809
|
+
reclamations: dict[str, str] = {}
|
|
810
|
+
for file, parsed in parsed_files.items():
|
|
811
|
+
unconfirmed_at: int | None = None
|
|
812
|
+
for line in parsed.lines:
|
|
813
|
+
key = _sidecar_key(file, line.value["seq"], line.value["ordinal"])
|
|
814
|
+
if key in confirmed:
|
|
815
|
+
if unconfirmed_at is not None:
|
|
816
|
+
raise JsonlCorruptionError(
|
|
817
|
+
f"Confirmed record follows an unconfirmed tail in {file}"
|
|
818
|
+
)
|
|
819
|
+
elif unconfirmed_at is None:
|
|
820
|
+
unconfirmed_at = line.start
|
|
821
|
+
if unconfirmed_at is not None:
|
|
822
|
+
truncated = await self._fs.truncate_file(parsed.path, unconfirmed_at, context)
|
|
823
|
+
if isinstance(truncated, Err):
|
|
824
|
+
raise _error_from_file(f"tail truncation of {file}", truncated.error)
|
|
825
|
+
|
|
826
|
+
numeric_id = _sidecar_file_id(file)
|
|
827
|
+
confirmed_lines = [
|
|
828
|
+
line
|
|
829
|
+
for line in parsed.lines
|
|
830
|
+
if _sidecar_key(file, line.value["seq"], line.value["ordinal"]) in confirmed
|
|
831
|
+
]
|
|
832
|
+
retained_lines: list[_ParsedLine] | None = None
|
|
833
|
+
if file.startswith("task-") and numeric_id in terminal_tasks:
|
|
834
|
+
retained_lines = []
|
|
835
|
+
elif file.startswith("doc-"):
|
|
836
|
+
document_id = numeric_id
|
|
837
|
+
if document_id in retired_current_only:
|
|
838
|
+
retained_lines = []
|
|
839
|
+
elif document_id in latest_bases:
|
|
840
|
+
retained_lines = [
|
|
841
|
+
line
|
|
842
|
+
for line in confirmed_lines
|
|
843
|
+
if not self._is_before_latest_base(
|
|
844
|
+
latest_bases, document_id, line.value["seq"], line.value["ordinal"]
|
|
845
|
+
)
|
|
846
|
+
]
|
|
847
|
+
if retained_lines is not None and (
|
|
848
|
+
len(retained_lines) < len(confirmed_lines) or len(retained_lines) == 0
|
|
849
|
+
):
|
|
850
|
+
reclamations[file] = "".join(_json_line(line.value) for line in retained_lines)
|
|
851
|
+
await self._reclaim_sidecars(reclamations, context)
|
|
852
|
+
|
|
853
|
+
self._current_only_documents.update(current_only_documents)
|
|
854
|
+
for id, live in final_task_is_live.items():
|
|
855
|
+
if live:
|
|
856
|
+
self._live_task_sidecars.add(id)
|
|
857
|
+
|
|
858
|
+
@staticmethod
|
|
859
|
+
def _confirm_record(
|
|
860
|
+
marker: dict[str, Any],
|
|
861
|
+
ordinal: int,
|
|
862
|
+
file: str,
|
|
863
|
+
record_by_key: dict[str, _ParsedLine],
|
|
864
|
+
confirmed: set[str],
|
|
865
|
+
optional: bool,
|
|
866
|
+
) -> dict[str, Any] | None:
|
|
867
|
+
key = _sidecar_key(file, marker["seq"], ordinal)
|
|
868
|
+
if key in confirmed:
|
|
869
|
+
raise JsonlCorruptionError("Sidecar record is confirmed more than once")
|
|
870
|
+
line = record_by_key.get(key)
|
|
871
|
+
if line is None:
|
|
872
|
+
if optional:
|
|
873
|
+
return None
|
|
874
|
+
raise JsonlCorruptionError(
|
|
875
|
+
f"Missing confirmed sidecar record {file} at sequence {marker['seq']}"
|
|
876
|
+
)
|
|
877
|
+
confirmed.add(key)
|
|
878
|
+
return cast(dict[str, Any], line.value)
|
|
879
|
+
|
|
880
|
+
@staticmethod
|
|
881
|
+
def _is_before_latest_base(
|
|
882
|
+
latest_bases: dict[int, dict[str, Any]],
|
|
883
|
+
id: int,
|
|
884
|
+
seq: int,
|
|
885
|
+
ordinal: int,
|
|
886
|
+
) -> bool:
|
|
887
|
+
base = latest_bases.get(id)
|
|
888
|
+
return base is not None and (
|
|
889
|
+
seq < base["seq"] or (seq == base["seq"] and ordinal < base["ordinal"])
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
async def _read_lines(
|
|
893
|
+
self,
|
|
894
|
+
path: str,
|
|
895
|
+
name: str,
|
|
896
|
+
context: Context,
|
|
897
|
+
parse: Callable[[str, int], Any],
|
|
898
|
+
) -> _ParsedFile:
|
|
899
|
+
read = await self._fs.read_binary_file(path, context)
|
|
900
|
+
if isinstance(read, Err):
|
|
901
|
+
if read.error.code == "not_found":
|
|
902
|
+
return _ParsedFile(path, [])
|
|
903
|
+
raise _error_from_file(f"read of {name}", read.error)
|
|
904
|
+
data = read.value
|
|
905
|
+
complete_size = len(data)
|
|
906
|
+
if complete_size > 0 and data[complete_size - 1] != 0x0A:
|
|
907
|
+
complete_size = data.rfind(b"\n") + 1
|
|
908
|
+
truncated = await self._fs.truncate_file(path, complete_size, context)
|
|
909
|
+
if isinstance(truncated, Err):
|
|
910
|
+
raise _error_from_file(f"torn-line truncation of {name}", truncated.error)
|
|
911
|
+
lines: list[_ParsedLine] = []
|
|
912
|
+
start = 0
|
|
913
|
+
line_number = 1
|
|
914
|
+
for raw in data[:complete_size].split(b"\n")[:-1]:
|
|
915
|
+
try:
|
|
916
|
+
text = raw.decode("utf-8")
|
|
917
|
+
except UnicodeDecodeError as error:
|
|
918
|
+
raise JsonlCorruptionError(
|
|
919
|
+
f"Invalid UTF-8 in complete {name} line {line_number}", error
|
|
920
|
+
) from error
|
|
921
|
+
lines.append(_ParsedLine(parse(text, line_number), start))
|
|
922
|
+
start += len(raw) + 1
|
|
923
|
+
line_number += 1
|
|
924
|
+
return _ParsedFile(path, lines)
|
|
925
|
+
|
|
926
|
+
async def _resolve_file(self, file: str, context: Context) -> str:
|
|
927
|
+
path = await self._fs.join_path([self._directory, file], context)
|
|
928
|
+
if isinstance(path, Err):
|
|
929
|
+
raise _error_from_file("path join", path.error)
|
|
930
|
+
return path.value
|
|
931
|
+
|
|
932
|
+
async def _try_join(self, parts: Sequence[str], context: Context) -> str | None:
|
|
933
|
+
path = await self._fs.join_path(parts, context)
|
|
934
|
+
return None if isinstance(path, Err) else path.value
|
|
935
|
+
|
|
936
|
+
@property
|
|
937
|
+
def _store(self) -> MemoryStorage:
|
|
938
|
+
self._assert_usable()
|
|
939
|
+
return self._memory
|
|
940
|
+
|
|
941
|
+
def _poison(self, cause: Exception) -> JsonlStoragePoisonedError:
|
|
942
|
+
if self._poison_error is None:
|
|
943
|
+
self._poison_error = JsonlStoragePoisonedError(cause)
|
|
944
|
+
return self._poison_error
|
|
945
|
+
|
|
946
|
+
def _assert_usable(self) -> None:
|
|
947
|
+
if self._closed:
|
|
948
|
+
raise RuntimeError("JsonlStorage is closed")
|
|
949
|
+
if self._poison_error is not None:
|
|
950
|
+
raise self._poison_error
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
__all__ = [
|
|
954
|
+
"JsonlCorruptionError",
|
|
955
|
+
"JsonlStorage",
|
|
956
|
+
"JsonlStorageOptions",
|
|
957
|
+
"JsonlStoragePoisonedError",
|
|
958
|
+
]
|