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,1088 @@
|
|
|
1
|
+
"""One atomic Session transaction.
|
|
2
|
+
|
|
3
|
+
The callback sees table reads only before its first table write, and every document acquisition is
|
|
4
|
+
staged. Settlement validates ownership and terminal-task retirement before storage admission;
|
|
5
|
+
adoption swaps committed document values in memory only after storage succeeds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Literal, Protocol, cast
|
|
13
|
+
|
|
14
|
+
from ..context import Context
|
|
15
|
+
from ..delta import Op
|
|
16
|
+
from ..documents import (
|
|
17
|
+
AnyDocToken,
|
|
18
|
+
DocDefinition,
|
|
19
|
+
address_id,
|
|
20
|
+
check_record_scope,
|
|
21
|
+
check_record_version,
|
|
22
|
+
document_create,
|
|
23
|
+
materialize_document_value,
|
|
24
|
+
resolve_address,
|
|
25
|
+
)
|
|
26
|
+
from ..entries import Entry
|
|
27
|
+
from ..errors import ReadAfterWrite
|
|
28
|
+
from ..ids import (
|
|
29
|
+
ROOT_CONVERSATION_ID,
|
|
30
|
+
ConversationId,
|
|
31
|
+
DocumentId,
|
|
32
|
+
EntryId,
|
|
33
|
+
Seq,
|
|
34
|
+
SubmissionId,
|
|
35
|
+
TaskId,
|
|
36
|
+
)
|
|
37
|
+
from ..json import JsonObject, JsonValue, copy_json
|
|
38
|
+
from ..types import (
|
|
39
|
+
ConversationOwnership,
|
|
40
|
+
ConversationQuery,
|
|
41
|
+
ConversationRecord,
|
|
42
|
+
Cursor,
|
|
43
|
+
DocumentAddress,
|
|
44
|
+
DocumentCommitChange,
|
|
45
|
+
DocumentCopySource,
|
|
46
|
+
DocumentCreate,
|
|
47
|
+
DocumentRecord,
|
|
48
|
+
EntryDraft,
|
|
49
|
+
EntryQuery,
|
|
50
|
+
EntryRecord,
|
|
51
|
+
EntryWithHead,
|
|
52
|
+
Page,
|
|
53
|
+
Storage,
|
|
54
|
+
StorageWrite,
|
|
55
|
+
SubmissionCreate,
|
|
56
|
+
SubmissionRecord,
|
|
57
|
+
SubmissionSettlement,
|
|
58
|
+
TaskOptions,
|
|
59
|
+
TaskQuery,
|
|
60
|
+
TaskRecord,
|
|
61
|
+
entry_record,
|
|
62
|
+
)
|
|
63
|
+
from .drafts import DocumentDraft, PreparedDraft
|
|
64
|
+
from .forks import prepare_fork_document_copies
|
|
65
|
+
|
|
66
|
+
AnyTaskRecord = TaskRecord
|
|
67
|
+
_INTERNAL_SCAN_PAGE_SIZE = 256
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True)
|
|
71
|
+
class LoadedDocument:
|
|
72
|
+
"""One committed document incarnation owned by the Session tracker cache."""
|
|
73
|
+
|
|
74
|
+
address_id: str
|
|
75
|
+
record: DocumentRecord
|
|
76
|
+
stored_version: int
|
|
77
|
+
value_version: int
|
|
78
|
+
deltas_since_base: int
|
|
79
|
+
value: JsonObject
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TransactionHost(Protocol):
|
|
83
|
+
"""Session services used by a transaction while it holds the mutation line."""
|
|
84
|
+
|
|
85
|
+
storage: Storage
|
|
86
|
+
|
|
87
|
+
def now(self) -> int: ...
|
|
88
|
+
|
|
89
|
+
def cached(self, address_id: str) -> LoadedDocument | None: ...
|
|
90
|
+
|
|
91
|
+
async def load(
|
|
92
|
+
self,
|
|
93
|
+
definition: DocDefinition,
|
|
94
|
+
address_id: str,
|
|
95
|
+
address: DocumentAddress,
|
|
96
|
+
context: Context,
|
|
97
|
+
) -> LoadedDocument | None: ...
|
|
98
|
+
|
|
99
|
+
def install(self, document: LoadedDocument) -> None: ...
|
|
100
|
+
|
|
101
|
+
def evict(self, address_id: str, record_id: DocumentId) -> None: ...
|
|
102
|
+
|
|
103
|
+
async def conversation_created(self, tx: Transaction, record: ConversationRecord) -> None: ...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(slots=True)
|
|
107
|
+
class TransactionTask:
|
|
108
|
+
committed_read: asyncio.Future[AnyTaskRecord | None] | None = None
|
|
109
|
+
write: tuple[Literal["create", "replace"], AnyTaskRecord] | None = None
|
|
110
|
+
publication_conversation_id: int | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class TransactionScope:
|
|
115
|
+
conversation_id: ConversationId | None = None
|
|
116
|
+
task_id: TaskId | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(slots=True)
|
|
120
|
+
class _LoadedTarget:
|
|
121
|
+
document: LoadedDocument
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(slots=True)
|
|
125
|
+
class _CreatedTarget:
|
|
126
|
+
record: DocumentCreate
|
|
127
|
+
version: int
|
|
128
|
+
value: JsonObject
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(slots=True)
|
|
132
|
+
class _ForkCopyTarget:
|
|
133
|
+
record: DocumentCreate
|
|
134
|
+
source: DocumentCopySource
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(slots=True)
|
|
138
|
+
class _RetireOnlyTarget:
|
|
139
|
+
record: DocumentRecord
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
DocumentTarget = _LoadedTarget | _CreatedTarget | _ForkCopyTarget | _RetireOnlyTarget
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(slots=True)
|
|
146
|
+
class DocumentEntry:
|
|
147
|
+
address_id: str
|
|
148
|
+
address: DocumentAddress
|
|
149
|
+
definition: DocDefinition | None = None
|
|
150
|
+
draft: DocumentDraft | None = None
|
|
151
|
+
acquisition: asyncio.Future[DocumentDraft] | None = None
|
|
152
|
+
target: DocumentTarget | None = None
|
|
153
|
+
retire_on_commit: bool = False
|
|
154
|
+
prepared: PreparedDraft | None = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(slots=True)
|
|
158
|
+
class _PlanChange:
|
|
159
|
+
loaded: LoadedDocument | None
|
|
160
|
+
definition: DocDefinition | None
|
|
161
|
+
version: int
|
|
162
|
+
value: JsonObject
|
|
163
|
+
ops: list[Op]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(slots=True)
|
|
167
|
+
class DocumentPlan:
|
|
168
|
+
address_id: str
|
|
169
|
+
record: DocumentCreate | DocumentRecord
|
|
170
|
+
retire: bool = False
|
|
171
|
+
content: StorageWrite | None = None
|
|
172
|
+
change: _PlanChange | None = None
|
|
173
|
+
conversation_id: int | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
SubmissionChange = SubmissionSettlement | dict[str, Any]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _apply_submission_change(current: SubmissionRecord, change: SubmissionChange) -> SubmissionRecord:
|
|
180
|
+
if current["status"] in ("done", "unanswered"):
|
|
181
|
+
return current
|
|
182
|
+
if change.get("status") == "placed":
|
|
183
|
+
if current["status"] != "queued":
|
|
184
|
+
raise ValueError(f"Submission {current['id']} is not queued")
|
|
185
|
+
status = "placed" if current["type"] == "input" else "done"
|
|
186
|
+
return cast(SubmissionRecord, {**current, "status": status, "entry": change["entry"]})
|
|
187
|
+
if change.get("status") == "done" and current["status"] != "placed":
|
|
188
|
+
raise ValueError(f"Submission {current['id']} is not a placed input")
|
|
189
|
+
return cast(SubmissionRecord, {**current, **change})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Transaction:
|
|
193
|
+
"""Transaction for one Session commit callback."""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
host: TransactionHost,
|
|
198
|
+
context: Context,
|
|
199
|
+
scope: TransactionScope | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
self._host = host
|
|
202
|
+
self._context = context
|
|
203
|
+
self._scope = scope or TransactionScope()
|
|
204
|
+
self._pending: set[asyncio.Future[Any]] = set()
|
|
205
|
+
self._sealed = False
|
|
206
|
+
self._has_table_write = False
|
|
207
|
+
self._writes: list[StorageWrite] = []
|
|
208
|
+
self._created_conversation_ids: set[int] = set()
|
|
209
|
+
self._fork_source_conversation_ids: set[int] = set()
|
|
210
|
+
self._fork_source_document_ids: set[int] = set()
|
|
211
|
+
self._tasks: dict[TaskId, TransactionTask] = {}
|
|
212
|
+
self._submissions: dict[SubmissionId, SubmissionRecord] = {}
|
|
213
|
+
self._submission_changes: list[tuple[SubmissionId, SubmissionChange]] = []
|
|
214
|
+
self._plans: list[DocumentPlan] = []
|
|
215
|
+
self._documents: list[DocumentEntry] = []
|
|
216
|
+
self._latest_document_by_address: dict[str, DocumentEntry] = {}
|
|
217
|
+
|
|
218
|
+
# ── Table reads ────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
async def conversation(self, id: ConversationId) -> ConversationRecord | None:
|
|
221
|
+
return await self._read("conversation", lambda: self._host.storage.conversation(id, self._context))
|
|
222
|
+
|
|
223
|
+
async def entry(self, first: EntryId | Entry[Any], second: EntryId | None = None) -> EntryRecord | None:
|
|
224
|
+
kind = None if isinstance(first, int) else first.kind
|
|
225
|
+
id = first if isinstance(first, int) else second
|
|
226
|
+
assert id is not None
|
|
227
|
+
|
|
228
|
+
async def read() -> EntryRecord | None:
|
|
229
|
+
stored = await self._host.storage.entry(id, self._context)
|
|
230
|
+
if stored is None:
|
|
231
|
+
return None
|
|
232
|
+
if kind is not None and stored["entry"].get("kind") != kind:
|
|
233
|
+
return None
|
|
234
|
+
return stored["entry"]
|
|
235
|
+
|
|
236
|
+
return await self._read("entry", read)
|
|
237
|
+
|
|
238
|
+
async def task(self, id: TaskId) -> AnyTaskRecord | None:
|
|
239
|
+
return await self._read("task", lambda: self._committed_task(id))
|
|
240
|
+
|
|
241
|
+
async def scan_conversations(
|
|
242
|
+
self,
|
|
243
|
+
query: ConversationQuery,
|
|
244
|
+
limit: int,
|
|
245
|
+
cursor: Cursor | None = None,
|
|
246
|
+
) -> Page[ConversationRecord, Cursor]:
|
|
247
|
+
return await self._read(
|
|
248
|
+
"scan_conversations",
|
|
249
|
+
lambda: self._host.storage.scan_conversations(query, limit, cursor, self._context),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def scan_entries(
|
|
253
|
+
self,
|
|
254
|
+
query: EntryQuery,
|
|
255
|
+
limit: int,
|
|
256
|
+
cursor: Cursor | None = None,
|
|
257
|
+
) -> Page[EntryRecord, Cursor]:
|
|
258
|
+
return await self._read(
|
|
259
|
+
"scan_entries",
|
|
260
|
+
lambda: self._host.storage.scan_entries(query, limit, cursor, self._context),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def latest_head_marker(self, conversation_id: ConversationId) -> EntryWithHead | None:
|
|
264
|
+
return await self._read(
|
|
265
|
+
"latest_head_marker",
|
|
266
|
+
lambda: self._host.storage.find_latest_head_marker(conversation_id, None, self._context),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
async def scan_tasks(
|
|
270
|
+
self,
|
|
271
|
+
query: TaskQuery,
|
|
272
|
+
limit: int,
|
|
273
|
+
cursor: Cursor | None = None,
|
|
274
|
+
) -> Page[TaskRecord, Cursor]:
|
|
275
|
+
return await self._read(
|
|
276
|
+
"scan_tasks",
|
|
277
|
+
lambda: self._host.storage.scan_tasks(query, limit, cursor, self._context),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
async def submission(self, id: SubmissionId) -> SubmissionRecord | None:
|
|
281
|
+
return await self._read("submission", lambda: self._host.storage.submission(id, self._context))
|
|
282
|
+
|
|
283
|
+
async def submission_by_request(
|
|
284
|
+
self,
|
|
285
|
+
conversation_id: ConversationId,
|
|
286
|
+
request_id: str,
|
|
287
|
+
) -> SubmissionRecord | None:
|
|
288
|
+
return await self._read(
|
|
289
|
+
"submission_by_request",
|
|
290
|
+
lambda: self._host.storage.submission_by_request(conversation_id, request_id, self._context),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# ── Table writes ───────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
async def create_conversation(self, options: dict[str, Any]) -> ConversationRecord:
|
|
296
|
+
ownership = cast(ConversationOwnership, options["ownership"])
|
|
297
|
+
return await self._write(lambda: self._stage_conversation(None, ownership))
|
|
298
|
+
|
|
299
|
+
async def create_root_conversation(self) -> ConversationRecord:
|
|
300
|
+
return await self._write(
|
|
301
|
+
lambda: self._stage_conversation(None, {"kind": "ownerless"}, ROOT_CONVERSATION_ID)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
async def fork_conversation(
|
|
305
|
+
self,
|
|
306
|
+
parent_conversation_id: ConversationId,
|
|
307
|
+
at: EntryId,
|
|
308
|
+
options: dict[str, Any],
|
|
309
|
+
) -> ConversationRecord:
|
|
310
|
+
ownership = cast(ConversationOwnership, options["ownership"])
|
|
311
|
+
return await self._write(
|
|
312
|
+
lambda: self._stage_conversation(
|
|
313
|
+
{"conversationId": parent_conversation_id, "at": at},
|
|
314
|
+
ownership,
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
async def _stage_conversation(
|
|
319
|
+
self,
|
|
320
|
+
parent: dict[str, Any] | None,
|
|
321
|
+
ownership: ConversationOwnership,
|
|
322
|
+
reserved_id: ConversationId | None = None,
|
|
323
|
+
) -> ConversationRecord:
|
|
324
|
+
owner_task_id = ownership["taskId"] if ownership["kind"] == "task" else None
|
|
325
|
+
id = (
|
|
326
|
+
reserved_id
|
|
327
|
+
if reserved_id is not None
|
|
328
|
+
else cast(ConversationId, await self._host.storage.mint_id())
|
|
329
|
+
)
|
|
330
|
+
self._assert_open()
|
|
331
|
+
owner: dict[str, Any] | None = None
|
|
332
|
+
if owner_task_id is not None:
|
|
333
|
+
task = await self._current_task(owner_task_id)
|
|
334
|
+
self._assert_open()
|
|
335
|
+
if task is None:
|
|
336
|
+
raise ValueError(f"Conversation owner task {owner_task_id} does not exist")
|
|
337
|
+
owner = {"conversationId": task["conversationId"], "taskId": owner_task_id}
|
|
338
|
+
record: dict[str, Any] = {"id": id}
|
|
339
|
+
if parent is not None:
|
|
340
|
+
record["parent"] = parent
|
|
341
|
+
if owner is not None:
|
|
342
|
+
record["owner"] = owner
|
|
343
|
+
copies = (
|
|
344
|
+
[]
|
|
345
|
+
if parent is None
|
|
346
|
+
else await prepare_fork_document_copies(
|
|
347
|
+
self._host.storage,
|
|
348
|
+
parent["conversationId"],
|
|
349
|
+
parent["at"],
|
|
350
|
+
id,
|
|
351
|
+
self._context,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
self._assert_open()
|
|
355
|
+
for copy in copies:
|
|
356
|
+
self._fork_source_document_ids.add(copy.source["id"])
|
|
357
|
+
entry = DocumentEntry(
|
|
358
|
+
address_id=address_id(copy.record),
|
|
359
|
+
address={"kind": copy.record["kind"], "scope": copy.record["scope"]},
|
|
360
|
+
target=_ForkCopyTarget(copy.record, copy.source),
|
|
361
|
+
)
|
|
362
|
+
if "key" in copy.record:
|
|
363
|
+
entry.address["key"] = copy.record["key"]
|
|
364
|
+
self._documents.append(entry)
|
|
365
|
+
self._latest_document_by_address[entry.address_id] = entry
|
|
366
|
+
if parent is not None:
|
|
367
|
+
self._fork_source_conversation_ids.add(parent["conversationId"])
|
|
368
|
+
self._created_conversation_ids.add(id)
|
|
369
|
+
self._writes.append(cast(StorageWrite, {"type": "conversation", "value": record}))
|
|
370
|
+
await self._host.conversation_created(self, cast(ConversationRecord, record))
|
|
371
|
+
self._assert_open()
|
|
372
|
+
return cast(ConversationRecord, record)
|
|
373
|
+
|
|
374
|
+
async def append_entry(
|
|
375
|
+
self,
|
|
376
|
+
first: ConversationId | Entry[Any],
|
|
377
|
+
second: ConversationId | EntryDraft,
|
|
378
|
+
third: EntryDraft | None = None,
|
|
379
|
+
) -> EntryRecord:
|
|
380
|
+
if isinstance(first, int):
|
|
381
|
+
conversation_id = first
|
|
382
|
+
value = cast(EntryDraft, second)
|
|
383
|
+
else:
|
|
384
|
+
conversation_id = cast(ConversationId, second)
|
|
385
|
+
value = cast(EntryDraft, {**(third or {}), "kind": first.kind})
|
|
386
|
+
return await self._write(lambda: self._append_entry(conversation_id, value))
|
|
387
|
+
|
|
388
|
+
async def _append_entry(self, conversation_id: ConversationId, value: EntryDraft) -> EntryRecord:
|
|
389
|
+
await self._require_conversation(conversation_id)
|
|
390
|
+
self._assert_open()
|
|
391
|
+
id = cast(EntryId, await self._host.storage.mint_id())
|
|
392
|
+
self._assert_open()
|
|
393
|
+
record = entry_record(id, conversation_id, value, by_task_id=self._scope.task_id)
|
|
394
|
+
self._writes.append({"type": "entry", "value": record})
|
|
395
|
+
return record
|
|
396
|
+
|
|
397
|
+
async def create_task(self, task: Any, input: JsonValue, options: TaskOptions) -> TaskId:
|
|
398
|
+
return await self._write(lambda: self._create_task(task, input, options))
|
|
399
|
+
|
|
400
|
+
async def _create_task(self, task: Any, input: JsonValue, options: TaskOptions) -> TaskId:
|
|
401
|
+
ownership = options["ownership"]
|
|
402
|
+
owner: AnyTaskRecord | None = None
|
|
403
|
+
if ownership["kind"] == "task":
|
|
404
|
+
owner = await self._current_task(ownership["taskId"])
|
|
405
|
+
self._assert_open()
|
|
406
|
+
if owner is None:
|
|
407
|
+
raise ValueError(f"Task owner {ownership['taskId']} does not exist")
|
|
408
|
+
if options.get("background") is True:
|
|
409
|
+
raise TypeError("A child task cannot be background")
|
|
410
|
+
if (
|
|
411
|
+
options.get("conversationId") is not None
|
|
412
|
+
and options["conversationId"] != owner["conversationId"]
|
|
413
|
+
):
|
|
414
|
+
raise ValueError(
|
|
415
|
+
f"A child task lives in its owner's conversation {owner['conversationId']}"
|
|
416
|
+
)
|
|
417
|
+
conversation_id = (
|
|
418
|
+
(owner["conversationId"] if owner is not None else None)
|
|
419
|
+
or options.get("conversationId")
|
|
420
|
+
or self._scope.conversation_id
|
|
421
|
+
)
|
|
422
|
+
if conversation_id is None:
|
|
423
|
+
raise TypeError("Tx.createTask() requires options.conversationId")
|
|
424
|
+
await self._require_conversation(conversation_id)
|
|
425
|
+
self._assert_open()
|
|
426
|
+
definition = task.definition
|
|
427
|
+
checkpoint = copy_json(definition.initial(input))
|
|
428
|
+
id = cast(TaskId, await self._host.storage.mint_id())
|
|
429
|
+
self._assert_open()
|
|
430
|
+
record: dict[str, Any] = {
|
|
431
|
+
"id": id,
|
|
432
|
+
"conversationId": conversation_id,
|
|
433
|
+
"kind": definition.name,
|
|
434
|
+
"version": definition.version,
|
|
435
|
+
"input": copy_json(input),
|
|
436
|
+
"background": options.get("background", False),
|
|
437
|
+
"abortRequested": False,
|
|
438
|
+
"state": {"status": "pending", "checkpoint": checkpoint},
|
|
439
|
+
}
|
|
440
|
+
if owner is not None:
|
|
441
|
+
record["owner"] = owner["id"]
|
|
442
|
+
if options.get("abandonOnRestart") is True:
|
|
443
|
+
record["abandonOnRestart"] = True
|
|
444
|
+
self._task_entry(id).write = ("create", cast(AnyTaskRecord, copy_json(record)))
|
|
445
|
+
return id
|
|
446
|
+
|
|
447
|
+
async def create_submission(self, create: SubmissionCreate) -> SubmissionRecord:
|
|
448
|
+
return await self._write(lambda: self._create_submission(create))
|
|
449
|
+
|
|
450
|
+
async def _create_submission(self, create: SubmissionCreate) -> SubmissionRecord:
|
|
451
|
+
await self._require_conversation(create["conversationId"])
|
|
452
|
+
self._assert_open()
|
|
453
|
+
id = cast(SubmissionId, await self._host.storage.mint_id())
|
|
454
|
+
self._assert_open()
|
|
455
|
+
record = cast(SubmissionRecord, copy_json({**create, "id": id}))
|
|
456
|
+
self._submissions[id] = record
|
|
457
|
+
return record
|
|
458
|
+
|
|
459
|
+
def settle_submission(self, id: SubmissionId, settlement: SubmissionSettlement) -> None:
|
|
460
|
+
self._assert_open()
|
|
461
|
+
self._has_table_write = True
|
|
462
|
+
self._submission_changes.append((id, cast(SubmissionChange, copy_json(settlement))))
|
|
463
|
+
|
|
464
|
+
def place_submission(self, id: SubmissionId, entry: EntryId) -> None:
|
|
465
|
+
self._assert_open()
|
|
466
|
+
self._has_table_write = True
|
|
467
|
+
self._submission_changes.append((id, {"status": "placed", "entry": entry}))
|
|
468
|
+
|
|
469
|
+
def set_task(self, value: AnyTaskRecord) -> None:
|
|
470
|
+
self._assert_open()
|
|
471
|
+
self._has_table_write = True
|
|
472
|
+
task = self._task_entry(value["id"])
|
|
473
|
+
candidate = task.write[1] if task.write is not None else None
|
|
474
|
+
if candidate is not None and candidate["state"]["status"] == "terminal":
|
|
475
|
+
raise ValueError(f"Task {value['id']} already has a terminal candidate")
|
|
476
|
+
if candidate is not None and candidate["conversationId"] != value["conversationId"]:
|
|
477
|
+
raise ValueError(f"Task {value['id']} cannot change conversations")
|
|
478
|
+
kind = "create" if task.write is not None and task.write[0] == "create" else "replace"
|
|
479
|
+
task.write = (kind, cast(AnyTaskRecord, copy_json(self._stamp_times(value, candidate))))
|
|
480
|
+
|
|
481
|
+
def staged_tasks(self) -> list[AnyTaskRecord]:
|
|
482
|
+
return [task.write[1] for task in self._tasks.values() if task.write is not None]
|
|
483
|
+
|
|
484
|
+
def staged_conversations(self) -> list[ConversationRecord]:
|
|
485
|
+
return [
|
|
486
|
+
write["value"]
|
|
487
|
+
for write in self._writes
|
|
488
|
+
if write["type"] == "conversation"
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
# ── Documents ──────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
async def doc(self, token: AnyDocToken, *args: Any) -> DocumentDraft:
|
|
494
|
+
self._assert_open()
|
|
495
|
+
definition = token.definition
|
|
496
|
+
resolved = resolve_address(definition, args)
|
|
497
|
+
self._assert_task_documents_open(resolved.address)
|
|
498
|
+
latest = self._latest_document_by_address.get(resolved.id)
|
|
499
|
+
if latest is not None and not latest.retire_on_commit:
|
|
500
|
+
if latest.draft is not None:
|
|
501
|
+
return latest.draft
|
|
502
|
+
if latest.acquisition is not None:
|
|
503
|
+
return await latest.acquisition
|
|
504
|
+
if isinstance(latest.target, _ForkCopyTarget):
|
|
505
|
+
latest.acquisition = asyncio.ensure_future(
|
|
506
|
+
self._track(self._acquire_fork_copy(latest, definition, latest.target))
|
|
507
|
+
)
|
|
508
|
+
return await latest.acquisition
|
|
509
|
+
seed = copy_json(args[resolved.next_argument]) if definition.family else None
|
|
510
|
+
entry = DocumentEntry(address_id=resolved.id, address=resolved.address, definition=definition)
|
|
511
|
+
self._documents.append(entry)
|
|
512
|
+
self._latest_document_by_address[entry.address_id] = entry
|
|
513
|
+
entry.acquisition = asyncio.ensure_future(
|
|
514
|
+
self._track(self._acquire(entry, seed, latest is not None and latest.retire_on_commit))
|
|
515
|
+
)
|
|
516
|
+
return await entry.acquisition
|
|
517
|
+
|
|
518
|
+
async def retrieve_doc_draft(self, entry: DocumentEntry) -> DocumentDraft:
|
|
519
|
+
if entry.draft is not None:
|
|
520
|
+
return entry.draft
|
|
521
|
+
if entry.acquisition is not None:
|
|
522
|
+
return await entry.acquisition
|
|
523
|
+
target = entry.target
|
|
524
|
+
if isinstance(target, _CreatedTarget):
|
|
525
|
+
entry.draft = DocumentDraft(target.value)
|
|
526
|
+
return entry.draft
|
|
527
|
+
if isinstance(target, _LoadedTarget):
|
|
528
|
+
entry.draft = DocumentDraft(target.document.value)
|
|
529
|
+
return entry.draft
|
|
530
|
+
raise RuntimeError("Document target has no draft")
|
|
531
|
+
|
|
532
|
+
async def retire_doc(self, token: AnyDocToken, *args: Any) -> None:
|
|
533
|
+
self._assert_open()
|
|
534
|
+
definition = token.definition
|
|
535
|
+
resolved = resolve_address(definition, args)
|
|
536
|
+
latest = self._latest_document_by_address.get(resolved.id)
|
|
537
|
+
if latest is not None and latest.retire_on_commit:
|
|
538
|
+
return
|
|
539
|
+
if latest is not None and isinstance(latest.target, _ForkCopyTarget):
|
|
540
|
+
latest.retire_on_commit = True
|
|
541
|
+
return
|
|
542
|
+
if latest is not None and (latest.draft is not None or latest.acquisition is not None):
|
|
543
|
+
latest.retire_on_commit = True
|
|
544
|
+
await self.retrieve_doc_draft(latest)
|
|
545
|
+
return
|
|
546
|
+
entry = DocumentEntry(
|
|
547
|
+
address_id=resolved.id,
|
|
548
|
+
address=resolved.address,
|
|
549
|
+
definition=definition,
|
|
550
|
+
retire_on_commit=True,
|
|
551
|
+
)
|
|
552
|
+
self._documents.append(entry)
|
|
553
|
+
self._latest_document_by_address[entry.address_id] = entry
|
|
554
|
+
await self._track(self._find_retirement(entry))
|
|
555
|
+
|
|
556
|
+
async def _acquire(
|
|
557
|
+
self,
|
|
558
|
+
entry: DocumentEntry,
|
|
559
|
+
seed: JsonValue | None,
|
|
560
|
+
skip_load: bool,
|
|
561
|
+
) -> DocumentDraft:
|
|
562
|
+
definition = entry.definition
|
|
563
|
+
assert definition is not None
|
|
564
|
+
loaded = (
|
|
565
|
+
None
|
|
566
|
+
if skip_load
|
|
567
|
+
else await self._host.load(definition, entry.address_id, entry.address, self._context)
|
|
568
|
+
)
|
|
569
|
+
self._assert_open()
|
|
570
|
+
if loaded is not None:
|
|
571
|
+
check_record_scope(definition, loaded.record)
|
|
572
|
+
check_record_version(definition, loaded.record, loaded.stored_version)
|
|
573
|
+
entry.target = _LoadedTarget(loaded)
|
|
574
|
+
entry.draft = DocumentDraft(loaded.value)
|
|
575
|
+
return entry.draft
|
|
576
|
+
scope = entry.address["scope"]
|
|
577
|
+
if scope["kind"] == "conversation":
|
|
578
|
+
await self._require_conversation(scope["conversationId"])
|
|
579
|
+
elif scope["kind"] == "task":
|
|
580
|
+
task = await self._current_task(scope["taskId"])
|
|
581
|
+
if task is None:
|
|
582
|
+
raise ValueError(f"Task {scope['taskId']} does not exist")
|
|
583
|
+
if task["state"]["status"] == "terminal":
|
|
584
|
+
raise ValueError(f"Task {scope['taskId']} is terminal")
|
|
585
|
+
self._assert_open()
|
|
586
|
+
value = copy_json(
|
|
587
|
+
definition.initial(seed) if definition.family else definition.initial()
|
|
588
|
+
)
|
|
589
|
+
if not isinstance(value, dict):
|
|
590
|
+
raise TypeError(f"Document {definition.kind} initial() must return an object")
|
|
591
|
+
id = cast(DocumentId, await self._host.storage.mint_id())
|
|
592
|
+
self._assert_open()
|
|
593
|
+
record = document_create(definition, entry.address, id)
|
|
594
|
+
entry.target = _CreatedTarget(record, definition.version, value)
|
|
595
|
+
entry.draft = DocumentDraft(value)
|
|
596
|
+
return entry.draft
|
|
597
|
+
|
|
598
|
+
async def _acquire_fork_copy(
|
|
599
|
+
self,
|
|
600
|
+
entry: DocumentEntry,
|
|
601
|
+
definition: DocDefinition,
|
|
602
|
+
target: _ForkCopyTarget,
|
|
603
|
+
) -> DocumentDraft:
|
|
604
|
+
stored = await self._host.storage.document(target.source["id"], target.source["at"], self._context)
|
|
605
|
+
self._assert_open()
|
|
606
|
+
if stored is None:
|
|
607
|
+
raise ValueError(f"Fork source document {target.source['id']} cannot be read")
|
|
608
|
+
record = stored["record"]
|
|
609
|
+
if (
|
|
610
|
+
record["scope"]["kind"] != "conversation"
|
|
611
|
+
or record["kind"] != target.record["kind"]
|
|
612
|
+
or record.get("key") != target.record.get("key")
|
|
613
|
+
or record.get("history") != target.record.get("history")
|
|
614
|
+
or record.get("fork") != target.record.get("fork")
|
|
615
|
+
):
|
|
616
|
+
raise ValueError(f"Fork source document {target.source['id']} does not match the copied record")
|
|
617
|
+
value = materialize_document_value(definition, target.record, stored["version"], stored["value"])
|
|
618
|
+
entry.definition = definition
|
|
619
|
+
entry.target = _CreatedTarget(target.record, definition.version, value)
|
|
620
|
+
entry.draft = DocumentDraft(value)
|
|
621
|
+
return entry.draft
|
|
622
|
+
|
|
623
|
+
async def _find_retirement(self, entry: DocumentEntry) -> None:
|
|
624
|
+
cached = self._host.cached(entry.address_id)
|
|
625
|
+
record = (
|
|
626
|
+
cached.record
|
|
627
|
+
if cached is not None
|
|
628
|
+
else await self._host.storage.find_document(entry.address, "current", self._context)
|
|
629
|
+
)
|
|
630
|
+
self._assert_open()
|
|
631
|
+
if record is None:
|
|
632
|
+
return
|
|
633
|
+
assert entry.definition is not None
|
|
634
|
+
check_record_scope(entry.definition, record)
|
|
635
|
+
entry.target = _RetireOnlyTarget(record)
|
|
636
|
+
|
|
637
|
+
# ── Settlement ─────────────────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
async def settle_failure(self) -> None:
|
|
640
|
+
self._sealed = True
|
|
641
|
+
for document in self._documents:
|
|
642
|
+
if document.draft is not None:
|
|
643
|
+
document.draft._seal()
|
|
644
|
+
await asyncio.gather(*list(self._pending), return_exceptions=True)
|
|
645
|
+
|
|
646
|
+
async def settle_success(self) -> list[StorageWrite]:
|
|
647
|
+
self._sealed = True
|
|
648
|
+
if self._pending:
|
|
649
|
+
for document in self._documents:
|
|
650
|
+
if document.draft is not None:
|
|
651
|
+
document.draft._seal()
|
|
652
|
+
await asyncio.gather(*list(self._pending), return_exceptions=True)
|
|
653
|
+
raise RuntimeError("Session commit callback settled before its pending Tx operations")
|
|
654
|
+
try:
|
|
655
|
+
for document in self._documents:
|
|
656
|
+
if document.draft is not None:
|
|
657
|
+
document.prepared = document.draft._prepare()
|
|
658
|
+
return await self._assemble()
|
|
659
|
+
except Exception:
|
|
660
|
+
for document in self._documents:
|
|
661
|
+
if document.draft is not None:
|
|
662
|
+
document.draft._seal()
|
|
663
|
+
raise
|
|
664
|
+
|
|
665
|
+
def discard(self) -> None:
|
|
666
|
+
for document in self._documents:
|
|
667
|
+
if document.draft is not None:
|
|
668
|
+
document.draft._seal()
|
|
669
|
+
|
|
670
|
+
def adopt(self, seq: Seq) -> list[DocumentCommitChange]:
|
|
671
|
+
publications: list[DocumentCommitChange] = []
|
|
672
|
+
for plan in self._plans:
|
|
673
|
+
committed = "createdAt" in plan.record
|
|
674
|
+
record: DocumentRecord = (
|
|
675
|
+
cast(DocumentRecord, plan.record)
|
|
676
|
+
if committed
|
|
677
|
+
else cast(DocumentRecord, {**plan.record, "createdAt": seq})
|
|
678
|
+
)
|
|
679
|
+
if plan.retire:
|
|
680
|
+
record = cast(DocumentRecord, {**record, "retiredAt": seq})
|
|
681
|
+
change = plan.change
|
|
682
|
+
if plan.change is not None:
|
|
683
|
+
loaded = plan.change.loaded
|
|
684
|
+
if loaded is None:
|
|
685
|
+
if not plan.retire:
|
|
686
|
+
self._host.install(
|
|
687
|
+
LoadedDocument(
|
|
688
|
+
address_id=plan.address_id,
|
|
689
|
+
record=record,
|
|
690
|
+
stored_version=plan.change.version,
|
|
691
|
+
value_version=plan.change.version,
|
|
692
|
+
deltas_since_base=0,
|
|
693
|
+
value=plan.change.value,
|
|
694
|
+
)
|
|
695
|
+
)
|
|
696
|
+
else:
|
|
697
|
+
if plan.content is not None:
|
|
698
|
+
loaded.value = plan.change.value
|
|
699
|
+
loaded.stored_version = plan.change.version
|
|
700
|
+
if plan.content["type"] == "document.change":
|
|
701
|
+
content = plan.content["content"]
|
|
702
|
+
if content["kind"] == "base":
|
|
703
|
+
loaded.deltas_since_base = 0
|
|
704
|
+
else:
|
|
705
|
+
loaded.deltas_since_base += 1
|
|
706
|
+
if plan.retire:
|
|
707
|
+
if committed:
|
|
708
|
+
self._host.evict(plan.address_id, record["id"])
|
|
709
|
+
publications.append(
|
|
710
|
+
{
|
|
711
|
+
"type": "document",
|
|
712
|
+
"record": record,
|
|
713
|
+
"conversationId": plan.conversation_id,
|
|
714
|
+
"version": None,
|
|
715
|
+
"value": None,
|
|
716
|
+
"ops": [],
|
|
717
|
+
}
|
|
718
|
+
)
|
|
719
|
+
elif plan.content is not None and plan.content["type"] == "document.copy":
|
|
720
|
+
publications.append(
|
|
721
|
+
{
|
|
722
|
+
"type": "document.copy",
|
|
723
|
+
"record": record,
|
|
724
|
+
"conversationId": plan.conversation_id,
|
|
725
|
+
"source": plan.content["source"],
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
elif change is not None and (change.loaded is None or plan.content is not None):
|
|
729
|
+
publications.append(
|
|
730
|
+
{
|
|
731
|
+
"type": "document",
|
|
732
|
+
"record": record,
|
|
733
|
+
"conversationId": plan.conversation_id,
|
|
734
|
+
"version": change.version,
|
|
735
|
+
"value": change.value,
|
|
736
|
+
"ops": [] if change.loaded is None else change.ops,
|
|
737
|
+
}
|
|
738
|
+
)
|
|
739
|
+
return publications
|
|
740
|
+
|
|
741
|
+
async def _assemble(self) -> list[StorageWrite]:
|
|
742
|
+
storage = self._host.storage
|
|
743
|
+
for document in self._documents:
|
|
744
|
+
plan = self._plan_document(document)
|
|
745
|
+
if plan is not None:
|
|
746
|
+
self._plans.append(plan)
|
|
747
|
+
self._reject_fork_source_writes(self._plans)
|
|
748
|
+
await self._validate_owners()
|
|
749
|
+
for id, task in self._tasks.items():
|
|
750
|
+
if task.write is None or task.write[0] != "replace":
|
|
751
|
+
continue
|
|
752
|
+
committed = await self._committed_task(id)
|
|
753
|
+
if committed is None:
|
|
754
|
+
raise ValueError(f"Task {id} does not exist")
|
|
755
|
+
if committed["state"]["status"] == "terminal":
|
|
756
|
+
raise ValueError(f"Task {id} is already terminal")
|
|
757
|
+
if committed["conversationId"] != task.write[1]["conversationId"]:
|
|
758
|
+
raise ValueError(f"Task {id} cannot change conversations")
|
|
759
|
+
|
|
760
|
+
terminal_task_ids: set[int] = set()
|
|
761
|
+
for task in self._tasks.values():
|
|
762
|
+
if task.write is not None and task.write[1]["state"]["status"] == "terminal":
|
|
763
|
+
terminal_task_ids.add(task.write[1]["id"])
|
|
764
|
+
if terminal_task_ids:
|
|
765
|
+
retiring: set[int] = set()
|
|
766
|
+
for plan in self._plans:
|
|
767
|
+
scope = plan.record["scope"]
|
|
768
|
+
if scope["kind"] != "task" or scope["taskId"] not in terminal_task_ids:
|
|
769
|
+
continue
|
|
770
|
+
plan.retire = True
|
|
771
|
+
retiring.add(plan.record["id"])
|
|
772
|
+
for task_id in terminal_task_ids:
|
|
773
|
+
candidate = self._tasks.get(task_id)
|
|
774
|
+
if candidate is not None and candidate.write is not None and candidate.write[0] == "create":
|
|
775
|
+
continue
|
|
776
|
+
cursor: Cursor | None = None
|
|
777
|
+
while True:
|
|
778
|
+
page = await storage.scan_documents(
|
|
779
|
+
{"scope": {"kind": "task", "taskId": task_id}, "at": "current"},
|
|
780
|
+
_INTERNAL_SCAN_PAGE_SIZE,
|
|
781
|
+
cursor,
|
|
782
|
+
self._context,
|
|
783
|
+
)
|
|
784
|
+
for record in page["items"]:
|
|
785
|
+
if record["id"] in retiring:
|
|
786
|
+
continue
|
|
787
|
+
self._plans.append(
|
|
788
|
+
DocumentPlan(address_id=address_id(record), record=record, retire=True)
|
|
789
|
+
)
|
|
790
|
+
retiring.add(record["id"])
|
|
791
|
+
cursor = page.get("next")
|
|
792
|
+
if cursor is None:
|
|
793
|
+
break
|
|
794
|
+
|
|
795
|
+
for plan in self._plans:
|
|
796
|
+
if not self._publishes(plan):
|
|
797
|
+
continue
|
|
798
|
+
scope = plan.record["scope"]
|
|
799
|
+
if scope["kind"] == "conversation":
|
|
800
|
+
plan.conversation_id = scope["conversationId"]
|
|
801
|
+
continue
|
|
802
|
+
if scope["kind"] != "task":
|
|
803
|
+
continue
|
|
804
|
+
task = self._task_entry(scope["taskId"])
|
|
805
|
+
if task.publication_conversation_id is None:
|
|
806
|
+
current = await self._current_task(scope["taskId"])
|
|
807
|
+
if current is not None:
|
|
808
|
+
task.publication_conversation_id = current["conversationId"]
|
|
809
|
+
plan.conversation_id = task.publication_conversation_id
|
|
810
|
+
|
|
811
|
+
for id, change in self._submission_changes:
|
|
812
|
+
current = self._submissions.get(id)
|
|
813
|
+
if current is None:
|
|
814
|
+
current = await storage.submission(id, self._context)
|
|
815
|
+
if current is None:
|
|
816
|
+
raise ValueError(f"Submission {id} does not exist")
|
|
817
|
+
next_record = _apply_submission_change(current, change)
|
|
818
|
+
if next_record is not current:
|
|
819
|
+
self._submissions[id] = next_record
|
|
820
|
+
|
|
821
|
+
writes = self._writes
|
|
822
|
+
for value in self._submissions.values():
|
|
823
|
+
writes.append({"type": "submission", "value": value})
|
|
824
|
+
for task in self._tasks.values():
|
|
825
|
+
if task.write is not None:
|
|
826
|
+
writes.append({"type": "task", "value": task.write[1]})
|
|
827
|
+
for plan in self._plans:
|
|
828
|
+
change = plan.change
|
|
829
|
+
if (
|
|
830
|
+
plan.content is not None
|
|
831
|
+
and plan.content["type"] == "document.change"
|
|
832
|
+
and plan.content["content"]["kind"] == "delta"
|
|
833
|
+
and change is not None
|
|
834
|
+
and change.loaded is not None
|
|
835
|
+
and change.definition is not None
|
|
836
|
+
and change.definition.checkpoint_when is not None
|
|
837
|
+
):
|
|
838
|
+
info = {"deltasSinceBase": change.loaded.deltas_since_base}
|
|
839
|
+
if change.definition.checkpoint_when(
|
|
840
|
+
cast(JsonObject, change.value), change.ops, info
|
|
841
|
+
):
|
|
842
|
+
plan.content = cast(
|
|
843
|
+
StorageWrite,
|
|
844
|
+
{
|
|
845
|
+
**plan.content,
|
|
846
|
+
"content": {
|
|
847
|
+
"version": change.version,
|
|
848
|
+
"kind": "base",
|
|
849
|
+
"value": change.value,
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
)
|
|
853
|
+
if plan.content is not None:
|
|
854
|
+
writes.append(plan.content)
|
|
855
|
+
if plan.retire:
|
|
856
|
+
writes.append({"type": "document.retire", "id": plan.record["id"]})
|
|
857
|
+
return writes
|
|
858
|
+
|
|
859
|
+
# ── Helpers ────────────────────────────────────────────────────────────
|
|
860
|
+
|
|
861
|
+
async def _validate_owners(self) -> None:
|
|
862
|
+
owners: list[tuple[str, int]] = []
|
|
863
|
+
for write in self._writes:
|
|
864
|
+
if write["type"] != "conversation":
|
|
865
|
+
continue
|
|
866
|
+
owner = write["value"].get("owner")
|
|
867
|
+
if owner is not None:
|
|
868
|
+
owners.append(("Conversation owner task", owner["taskId"]))
|
|
869
|
+
for task in self._tasks.values():
|
|
870
|
+
if task.write is None or task.write[0] != "create":
|
|
871
|
+
continue
|
|
872
|
+
owner = task.write[1].get("owner")
|
|
873
|
+
if owner is not None:
|
|
874
|
+
owners.append(("Task owner", owner))
|
|
875
|
+
for what, task_id in owners:
|
|
876
|
+
task = await self._current_task(cast(TaskId, task_id))
|
|
877
|
+
if task is None:
|
|
878
|
+
raise ValueError(f"{what} {task_id} does not exist")
|
|
879
|
+
status = task["state"]["status"]
|
|
880
|
+
if status in ("terminal", "completing"):
|
|
881
|
+
raise ValueError(f"{what} {task_id} is {status}")
|
|
882
|
+
if task["abortRequested"]:
|
|
883
|
+
raise ValueError(f"{what} {task_id} is abort-marked")
|
|
884
|
+
|
|
885
|
+
def _reject_fork_source_writes(self, plans: list[DocumentPlan]) -> None:
|
|
886
|
+
for plan in plans:
|
|
887
|
+
if plan.content is None and not plan.retire:
|
|
888
|
+
continue
|
|
889
|
+
record = plan.record
|
|
890
|
+
if record["id"] in self._fork_source_document_ids:
|
|
891
|
+
raise ValueError(
|
|
892
|
+
f"Cannot change fork source document {record['id']} in the fork transaction"
|
|
893
|
+
)
|
|
894
|
+
scope = record["scope"]
|
|
895
|
+
if (
|
|
896
|
+
scope["kind"] == "conversation"
|
|
897
|
+
and scope["conversationId"] in self._fork_source_conversation_ids
|
|
898
|
+
and record["scope"]["kind"] == "conversation"
|
|
899
|
+
and record.get("fork") == "current"
|
|
900
|
+
):
|
|
901
|
+
raise ValueError(
|
|
902
|
+
f"Cannot fork conversation {scope['conversationId']} "
|
|
903
|
+
"while changing its current-policy documents"
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
def _plan_document(self, document: DocumentEntry) -> DocumentPlan | None:
|
|
907
|
+
target = document.target
|
|
908
|
+
if target is None:
|
|
909
|
+
return None
|
|
910
|
+
if isinstance(target, _RetireOnlyTarget):
|
|
911
|
+
return DocumentPlan(
|
|
912
|
+
address_id=document.address_id,
|
|
913
|
+
record=target.record,
|
|
914
|
+
retire=document.retire_on_commit,
|
|
915
|
+
)
|
|
916
|
+
if isinstance(target, _ForkCopyTarget):
|
|
917
|
+
return DocumentPlan(
|
|
918
|
+
address_id=document.address_id,
|
|
919
|
+
record=target.record,
|
|
920
|
+
retire=document.retire_on_commit,
|
|
921
|
+
content=cast(
|
|
922
|
+
StorageWrite,
|
|
923
|
+
{"type": "document.copy", "record": target.record, "source": target.source},
|
|
924
|
+
),
|
|
925
|
+
)
|
|
926
|
+
prepared = document.prepared
|
|
927
|
+
assert prepared is not None
|
|
928
|
+
if isinstance(target, _CreatedTarget):
|
|
929
|
+
return DocumentPlan(
|
|
930
|
+
address_id=document.address_id,
|
|
931
|
+
record=target.record,
|
|
932
|
+
retire=document.retire_on_commit,
|
|
933
|
+
content=cast(
|
|
934
|
+
StorageWrite,
|
|
935
|
+
{
|
|
936
|
+
"type": "document.create",
|
|
937
|
+
"record": target.record,
|
|
938
|
+
"content": {"version": target.version, "kind": "base", "value": prepared.value},
|
|
939
|
+
},
|
|
940
|
+
),
|
|
941
|
+
change=_PlanChange(
|
|
942
|
+
loaded=None,
|
|
943
|
+
definition=document.definition,
|
|
944
|
+
version=target.version,
|
|
945
|
+
value=prepared.value,
|
|
946
|
+
ops=[],
|
|
947
|
+
),
|
|
948
|
+
)
|
|
949
|
+
loaded = target.document
|
|
950
|
+
definition = document.definition
|
|
951
|
+
assert definition is not None
|
|
952
|
+
ops = prepared.ops
|
|
953
|
+
value = prepared.value
|
|
954
|
+
if loaded.stored_version < definition.version:
|
|
955
|
+
content = cast(
|
|
956
|
+
StorageWrite,
|
|
957
|
+
{
|
|
958
|
+
"type": "document.change",
|
|
959
|
+
"id": loaded.record["id"],
|
|
960
|
+
"content": {"version": definition.version, "kind": "base", "value": value},
|
|
961
|
+
},
|
|
962
|
+
)
|
|
963
|
+
elif ops:
|
|
964
|
+
content = cast(
|
|
965
|
+
StorageWrite,
|
|
966
|
+
{
|
|
967
|
+
"type": "document.change",
|
|
968
|
+
"id": loaded.record["id"],
|
|
969
|
+
"content": {"version": definition.version, "kind": "delta", "ops": ops},
|
|
970
|
+
},
|
|
971
|
+
)
|
|
972
|
+
else:
|
|
973
|
+
content = None
|
|
974
|
+
return DocumentPlan(
|
|
975
|
+
address_id=document.address_id,
|
|
976
|
+
record=loaded.record,
|
|
977
|
+
retire=document.retire_on_commit,
|
|
978
|
+
content=content,
|
|
979
|
+
change=_PlanChange(
|
|
980
|
+
loaded=loaded,
|
|
981
|
+
definition=definition,
|
|
982
|
+
version=definition.version,
|
|
983
|
+
value=value,
|
|
984
|
+
ops=ops,
|
|
985
|
+
),
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
@staticmethod
|
|
989
|
+
def _publishes(plan: DocumentPlan) -> bool:
|
|
990
|
+
return plan.retire or plan.change is None or plan.change.loaded is None or plan.content is not None
|
|
991
|
+
|
|
992
|
+
def _assert_open(self) -> None:
|
|
993
|
+
if self._sealed:
|
|
994
|
+
raise RuntimeError("Transaction has settled")
|
|
995
|
+
|
|
996
|
+
def _assert_task_documents_open(self, address: DocumentAddress) -> None:
|
|
997
|
+
scope = address["scope"]
|
|
998
|
+
if scope["kind"] != "task":
|
|
999
|
+
return
|
|
1000
|
+
task = self._tasks.get(scope["taskId"])
|
|
1001
|
+
if task is not None and task.write is not None and task.write[1]["state"]["status"] == "terminal":
|
|
1002
|
+
raise ValueError(f"Task {scope['taskId']} is terminal")
|
|
1003
|
+
|
|
1004
|
+
def _track(self, awaitable: Any) -> asyncio.Future[Any]:
|
|
1005
|
+
task = asyncio.ensure_future(awaitable)
|
|
1006
|
+
self._pending.add(task)
|
|
1007
|
+
|
|
1008
|
+
def settle(_: Any) -> None:
|
|
1009
|
+
self._pending.discard(task)
|
|
1010
|
+
|
|
1011
|
+
task.add_done_callback(settle)
|
|
1012
|
+
return task
|
|
1013
|
+
|
|
1014
|
+
async def _read(self, method: str, read: Any) -> Any:
|
|
1015
|
+
self._assert_open()
|
|
1016
|
+
if self._has_table_write:
|
|
1017
|
+
raise ReadAfterWrite(method)
|
|
1018
|
+
return await self._track(read())
|
|
1019
|
+
|
|
1020
|
+
async def _write(self, write: Any) -> Any:
|
|
1021
|
+
self._assert_open()
|
|
1022
|
+
self._has_table_write = True
|
|
1023
|
+
return await self._track(write())
|
|
1024
|
+
|
|
1025
|
+
async def _require_conversation(self, id: ConversationId) -> None:
|
|
1026
|
+
if id in self._created_conversation_ids:
|
|
1027
|
+
return
|
|
1028
|
+
if await self._host.storage.conversation(id, self._context) is None:
|
|
1029
|
+
raise ValueError(f"Conversation {id} does not exist")
|
|
1030
|
+
|
|
1031
|
+
def _stamp_times(self, value: AnyTaskRecord, candidate: AnyTaskRecord | None) -> AnyTaskRecord:
|
|
1032
|
+
status = value["state"]["status"]
|
|
1033
|
+
|
|
1034
|
+
def stamp_enabled(enabled: bool) -> int | None:
|
|
1035
|
+
return self._host.now() if enabled else None
|
|
1036
|
+
|
|
1037
|
+
def first_set(*values: int | None) -> int | None:
|
|
1038
|
+
return next((item for item in values if item is not None), None)
|
|
1039
|
+
|
|
1040
|
+
started_at = first_set(
|
|
1041
|
+
(candidate or {}).get("startedAt"),
|
|
1042
|
+
value.get("startedAt"),
|
|
1043
|
+
stamp_enabled(status == "running"),
|
|
1044
|
+
)
|
|
1045
|
+
ended_at = first_set(
|
|
1046
|
+
(candidate or {}).get("endedAt"),
|
|
1047
|
+
value.get("endedAt"),
|
|
1048
|
+
stamp_enabled(status == "terminal"),
|
|
1049
|
+
)
|
|
1050
|
+
if started_at == value.get("startedAt") and ended_at == value.get("endedAt"):
|
|
1051
|
+
return value
|
|
1052
|
+
result = dict(value)
|
|
1053
|
+
if started_at is None:
|
|
1054
|
+
result.pop("startedAt", None)
|
|
1055
|
+
else:
|
|
1056
|
+
result["startedAt"] = started_at
|
|
1057
|
+
if ended_at is None:
|
|
1058
|
+
result.pop("endedAt", None)
|
|
1059
|
+
else:
|
|
1060
|
+
result["endedAt"] = ended_at
|
|
1061
|
+
return cast(AnyTaskRecord, result)
|
|
1062
|
+
|
|
1063
|
+
def _task_entry(self, id: TaskId) -> TransactionTask:
|
|
1064
|
+
return self._tasks.setdefault(id, TransactionTask())
|
|
1065
|
+
|
|
1066
|
+
async def _current_task(self, id: TaskId) -> AnyTaskRecord | None:
|
|
1067
|
+
task = self._tasks.get(id)
|
|
1068
|
+
if task is not None and task.write is not None:
|
|
1069
|
+
return task.write[1]
|
|
1070
|
+
return await self._committed_task(id)
|
|
1071
|
+
|
|
1072
|
+
async def _committed_task(self, id: TaskId) -> AnyTaskRecord | None:
|
|
1073
|
+
task = self._task_entry(id)
|
|
1074
|
+
if task.committed_read is None:
|
|
1075
|
+
task.committed_read = asyncio.ensure_future(
|
|
1076
|
+
self._host.storage.task(id, self._context)
|
|
1077
|
+
)
|
|
1078
|
+
return await task.committed_read
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
__all__ = [
|
|
1082
|
+
"DocumentEntry",
|
|
1083
|
+
"DocumentPlan",
|
|
1084
|
+
"LoadedDocument",
|
|
1085
|
+
"Transaction",
|
|
1086
|
+
"TransactionHost",
|
|
1087
|
+
"TransactionScope",
|
|
1088
|
+
]
|