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,1193 @@
|
|
|
1
|
+
"""Session kernel: one mutation line, committed reads, and a bounded document cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import partial
|
|
10
|
+
from typing import Any, TypedDict, cast
|
|
11
|
+
|
|
12
|
+
from ..context import BACKGROUND_CONTEXT, Context, without_abort_signal
|
|
13
|
+
from ..documents import (
|
|
14
|
+
AnyDocToken,
|
|
15
|
+
DocDefinition,
|
|
16
|
+
address_id,
|
|
17
|
+
check_record_scope,
|
|
18
|
+
check_record_version,
|
|
19
|
+
materialize_document,
|
|
20
|
+
resolve_address,
|
|
21
|
+
)
|
|
22
|
+
from ..errors import SessionFailed, StorageRequestError, is_failure
|
|
23
|
+
from ..harness.context import read_context
|
|
24
|
+
from ..harness.types import ContextView
|
|
25
|
+
from ..ids import ConversationId, DocumentId, EntryId, Seq, TaskId
|
|
26
|
+
from ..json import JsonObject
|
|
27
|
+
from ..types import (
|
|
28
|
+
CommitChange,
|
|
29
|
+
CommitPublication,
|
|
30
|
+
ConversationQuery,
|
|
31
|
+
ConversationRecord,
|
|
32
|
+
Cursor,
|
|
33
|
+
DocumentAddress,
|
|
34
|
+
DocumentCommitChange,
|
|
35
|
+
DocumentPoint,
|
|
36
|
+
DocumentQuery,
|
|
37
|
+
DocumentRecord,
|
|
38
|
+
EntryLookup,
|
|
39
|
+
EntryQuery,
|
|
40
|
+
EntryRecord,
|
|
41
|
+
EntryWithHead,
|
|
42
|
+
Page,
|
|
43
|
+
SessionEnd,
|
|
44
|
+
Storage,
|
|
45
|
+
StorageWrite,
|
|
46
|
+
StoredDocument,
|
|
47
|
+
SubmissionQuery,
|
|
48
|
+
SubmissionRecord,
|
|
49
|
+
TaskQuery,
|
|
50
|
+
TaskRecord,
|
|
51
|
+
)
|
|
52
|
+
from .observation import (
|
|
53
|
+
CommittedState,
|
|
54
|
+
CommittedWatch,
|
|
55
|
+
ObservedDocumentValue,
|
|
56
|
+
observed_operations,
|
|
57
|
+
)
|
|
58
|
+
from .transaction import LoadedDocument, Transaction, TransactionScope
|
|
59
|
+
|
|
60
|
+
#: Default document retention: ten minutes, as for kept contexts.
|
|
61
|
+
_DEFAULT_DOCUMENT_RETENTION_MS = 600_000
|
|
62
|
+
|
|
63
|
+
#: Longest delay a loop timer accepts; a longer retention waits in several steps.
|
|
64
|
+
_MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
65
|
+
|
|
66
|
+
_default_document_retention: int | float = _DEFAULT_DOCUMENT_RETENTION_MS
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_default_document_retention(ms: int | float) -> None:
|
|
70
|
+
"""Internal, for tests: the retention of every Session that keeps the default."""
|
|
71
|
+
|
|
72
|
+
global _default_document_retention
|
|
73
|
+
_default_document_retention = ms
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def document_retention(ms: int | float | None) -> int | float:
|
|
77
|
+
"""Return a retention as given when it is a number of milliseconds, ``0`` or more.
|
|
78
|
+
|
|
79
|
+
``Infinity`` keeps documents; a value that is not a number of milliseconds means the
|
|
80
|
+
default, as v1.3.0 does.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
if isinstance(ms, (int, float)) and not isinstance(ms, bool) and ms >= 0:
|
|
84
|
+
return ms
|
|
85
|
+
return _default_document_retention
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _schedule_document_timer(
|
|
89
|
+
delay_ms: float, run: Callable[[], None]
|
|
90
|
+
) -> asyncio.TimerHandle | None:
|
|
91
|
+
"""Schedule one eviction that never keeps the process alive.
|
|
92
|
+
|
|
93
|
+
An asyncio timer cannot keep a process alive once its loop ends, so every Python loop can
|
|
94
|
+
keep one. ``None`` means this loop cannot, and then only commits evict (spec §4).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
return asyncio.get_running_loop().call_later(delay_ms / 1000.0, run)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_session(storage: Storage, options: dict[str, Any] | None = None, **kwargs: Any) -> SessionImpl:
|
|
101
|
+
"""Open one Session kernel over a storage backend."""
|
|
102
|
+
|
|
103
|
+
values = dict(options or {})
|
|
104
|
+
values.update(kwargs)
|
|
105
|
+
now = values.get("now")
|
|
106
|
+
retention = values.get("document_retention_ms", values.get("documentRetentionMs"))
|
|
107
|
+
if retention is not None and callable(retention):
|
|
108
|
+
retention = retention()
|
|
109
|
+
return SessionImpl(
|
|
110
|
+
storage,
|
|
111
|
+
now if callable(now) else _now_ms,
|
|
112
|
+
retention,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _now_ms() -> int:
|
|
117
|
+
return int(time.time() * 1000)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _validate_document(definition: DocDefinition, loaded: LoadedDocument) -> None:
|
|
121
|
+
"""Reject typed access whose token disagrees with the stored incarnation or cannot use it."""
|
|
122
|
+
|
|
123
|
+
check_record_scope(definition, loaded.record)
|
|
124
|
+
check_record_version(definition, loaded.record, loaded.stored_version)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _record_address_id(record: DocumentRecord) -> str:
|
|
128
|
+
"""Return the cache identity of one committed document record."""
|
|
129
|
+
|
|
130
|
+
address: DocumentAddress = {"kind": record["kind"], "scope": record["scope"]}
|
|
131
|
+
if "key" in record:
|
|
132
|
+
address["key"] = record["key"]
|
|
133
|
+
return address_id(address)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(slots=True)
|
|
137
|
+
class _WatchAttachment:
|
|
138
|
+
"""One watch subscribed to a committed incarnation, and the incarnation it pins."""
|
|
139
|
+
|
|
140
|
+
record_id: DocumentId
|
|
141
|
+
watch: CommittedWatch
|
|
142
|
+
release: Callable[[], None]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(slots=True)
|
|
146
|
+
class _DocumentSweep:
|
|
147
|
+
"""The pending eviction: a timer for the oldest idle document, or a line job at once."""
|
|
148
|
+
|
|
149
|
+
at: int | float
|
|
150
|
+
timer: asyncio.TimerHandle | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class DocumentCacheSizes(TypedDict):
|
|
154
|
+
"""Internal, for tests: how many documents the cache holds and pins, and its sweep jobs."""
|
|
155
|
+
|
|
156
|
+
cached: int
|
|
157
|
+
idle: int
|
|
158
|
+
attached: int
|
|
159
|
+
sweeps: int
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class DocumentState(CommittedState):
|
|
163
|
+
"""Disposable, read-only committed state bound to one document incarnation."""
|
|
164
|
+
|
|
165
|
+
__slots__ = ("_observed_version", "_record_id", "_session")
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
session: SessionImpl,
|
|
170
|
+
record_id: DocumentId,
|
|
171
|
+
value: JsonObject,
|
|
172
|
+
observed_version: int,
|
|
173
|
+
release: Callable[[], None],
|
|
174
|
+
) -> None:
|
|
175
|
+
super().__init__(value, release, session.report)
|
|
176
|
+
self._session = session
|
|
177
|
+
self._record_id = record_id
|
|
178
|
+
self._observed_version = observed_version
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def value(self) -> JsonObject | None:
|
|
182
|
+
return cast(JsonObject | None, self._value)
|
|
183
|
+
|
|
184
|
+
def dispose(self) -> None:
|
|
185
|
+
"""Idempotently stop future deliveries; the last value remains readable."""
|
|
186
|
+
|
|
187
|
+
if self._disposed:
|
|
188
|
+
return
|
|
189
|
+
super().dispose()
|
|
190
|
+
self._session._detach_state(self)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _SessionHost:
|
|
194
|
+
def __init__(self, session: SessionImpl) -> None:
|
|
195
|
+
self._session = session
|
|
196
|
+
self.storage = cast(Storage, session._storage) # noqa: SLF001
|
|
197
|
+
|
|
198
|
+
def now(self) -> int:
|
|
199
|
+
return self._session._clock()
|
|
200
|
+
|
|
201
|
+
def cached(self, address_id: str) -> LoadedDocument | None:
|
|
202
|
+
return self._session._cached(address_id)
|
|
203
|
+
|
|
204
|
+
async def load(
|
|
205
|
+
self,
|
|
206
|
+
definition: DocDefinition,
|
|
207
|
+
address_id: str,
|
|
208
|
+
address: DocumentAddress,
|
|
209
|
+
context: Context,
|
|
210
|
+
) -> LoadedDocument | None:
|
|
211
|
+
return await self._session._load_document(definition, address_id, address, context)
|
|
212
|
+
|
|
213
|
+
def install(self, document: LoadedDocument) -> None:
|
|
214
|
+
self._session._install(document)
|
|
215
|
+
|
|
216
|
+
def evict(self, address_id: str, record_id: DocumentId) -> None:
|
|
217
|
+
self._session._evict(address_id, record_id)
|
|
218
|
+
|
|
219
|
+
async def conversation_created(self, tx: Transaction, record: ConversationRecord) -> None:
|
|
220
|
+
await self._session.conversation_created(tx, record)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class SessionImpl:
|
|
224
|
+
"""Session kernel over one guarded storage backend."""
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
storage: Storage,
|
|
229
|
+
now: Callable[[], int] | None = None,
|
|
230
|
+
document_retention_ms: int | float | Callable[[], int | float | None] | None = None,
|
|
231
|
+
) -> None:
|
|
232
|
+
self._documents: dict[str, LoadedDocument] = {}
|
|
233
|
+
# Cached documents with no observer attached, by address, oldest use first.
|
|
234
|
+
self._idle_documents: dict[str, int | float] = {}
|
|
235
|
+
# Observers attached per incarnation (record ID); a cached incarnation with any stays.
|
|
236
|
+
self._attached_documents: dict[DocumentId, int] = {}
|
|
237
|
+
self._document_sweep: _DocumentSweep | None = None
|
|
238
|
+
self._sweep_jobs = 0
|
|
239
|
+
self._sweep_tasks: set[asyncio.Task[None]] = set()
|
|
240
|
+
self._document_timers_unavailable = False
|
|
241
|
+
self._states: list[DocumentState] = []
|
|
242
|
+
self._watches: list[_WatchAttachment] = []
|
|
243
|
+
self._commit_listeners: list[Callable[[CommitPublication, Context], Any]] = []
|
|
244
|
+
# The Session's own components follow every commit; a throw leaves their state behind storage.
|
|
245
|
+
self._internal_listeners: list[Callable[[CommitPublication, Context], Any]] = []
|
|
246
|
+
self._close_listeners: list[Callable[[], Any]] = []
|
|
247
|
+
self._line = asyncio.Lock()
|
|
248
|
+
self._closing: asyncio.Future[None] | None = None
|
|
249
|
+
self._failure: BaseException | None = None
|
|
250
|
+
loop = _current_loop()
|
|
251
|
+
self._failed: asyncio.Future[SessionFailed] = loop.create_future()
|
|
252
|
+
self._closed: asyncio.Future[SessionEnd] = loop.create_future()
|
|
253
|
+
self._failed.add_done_callback(lambda future: future.exception())
|
|
254
|
+
self._clock_fn = now or _now_ms
|
|
255
|
+
|
|
256
|
+
def clock() -> int:
|
|
257
|
+
try:
|
|
258
|
+
return self._clock_fn()
|
|
259
|
+
except Exception:
|
|
260
|
+
return _now_ms()
|
|
261
|
+
|
|
262
|
+
def retention() -> int | float:
|
|
263
|
+
try:
|
|
264
|
+
value = document_retention_ms() if callable(document_retention_ms) else document_retention_ms
|
|
265
|
+
return document_retention(value)
|
|
266
|
+
except Exception:
|
|
267
|
+
return document_retention(None)
|
|
268
|
+
|
|
269
|
+
self._clock = clock
|
|
270
|
+
self._document_retention_ms = retention
|
|
271
|
+
self._storage = GuardedStorage(storage, self)
|
|
272
|
+
self._host = _SessionHost(self)
|
|
273
|
+
|
|
274
|
+
# ── Lifecycle and commits ───────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def closed(self) -> asyncio.Future[SessionEnd]:
|
|
278
|
+
return self._closed
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def failure(self) -> BaseException | None:
|
|
282
|
+
return self._failure
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def now(self) -> int:
|
|
286
|
+
"""The Session's current Harness clock value."""
|
|
287
|
+
|
|
288
|
+
return self._clock()
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def storage(self) -> Storage:
|
|
292
|
+
"""The Session's guarded Storage, as the Harness components use it."""
|
|
293
|
+
|
|
294
|
+
return cast(Storage, self._storage)
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def failed(self) -> asyncio.Future[SessionFailed]:
|
|
298
|
+
return self._failed
|
|
299
|
+
|
|
300
|
+
def fail(self, error: BaseException) -> None:
|
|
301
|
+
"""Fail the Session once; the first failure wins and close listeners run immediately.
|
|
302
|
+
|
|
303
|
+
A teardown signal is not a failure: cancellation and interpreter shutdown unwind the work
|
|
304
|
+
that is running instead of ending the Session.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
if not is_failure(error) or self._failure is not None:
|
|
308
|
+
return
|
|
309
|
+
self._failure = error
|
|
310
|
+
if not self._failed.done():
|
|
311
|
+
self._failed.set_exception(SessionFailed(error))
|
|
312
|
+
try:
|
|
313
|
+
loop = asyncio.get_running_loop()
|
|
314
|
+
except RuntimeError:
|
|
315
|
+
return
|
|
316
|
+
loop.create_task(self._close_after_failure())
|
|
317
|
+
self.report(error)
|
|
318
|
+
|
|
319
|
+
async def _close_after_failure(self) -> None:
|
|
320
|
+
try:
|
|
321
|
+
await self.close(BACKGROUND_CONTEXT)
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
def report(self, error: BaseException) -> None:
|
|
326
|
+
"""A plain Session has no external report sink."""
|
|
327
|
+
|
|
328
|
+
async def commit(
|
|
329
|
+
self,
|
|
330
|
+
change: Callable[[Transaction], Any],
|
|
331
|
+
context: Context,
|
|
332
|
+
) -> Any:
|
|
333
|
+
return await self.commit_with(change, context)
|
|
334
|
+
|
|
335
|
+
async def commit_with(
|
|
336
|
+
self,
|
|
337
|
+
change: Callable[[Transaction], Any],
|
|
338
|
+
context: Context,
|
|
339
|
+
scope: TransactionScope | None = None,
|
|
340
|
+
) -> Any:
|
|
341
|
+
try:
|
|
342
|
+
self.assert_usable()
|
|
343
|
+
except Exception as error:
|
|
344
|
+
raise error
|
|
345
|
+
async with self._line:
|
|
346
|
+
self._assert_healthy()
|
|
347
|
+
signal = context.abort_signal
|
|
348
|
+
if signal is not None:
|
|
349
|
+
signal.raise_if_aborted()
|
|
350
|
+
tx = Transaction(self._host, context, scope)
|
|
351
|
+
try:
|
|
352
|
+
result = await change(tx)
|
|
353
|
+
self._assert_healthy()
|
|
354
|
+
except BaseException:
|
|
355
|
+
await tx.settle_failure()
|
|
356
|
+
self._sweep_documents()
|
|
357
|
+
raise
|
|
358
|
+
try:
|
|
359
|
+
writes = await tx.settle_success()
|
|
360
|
+
except BaseException:
|
|
361
|
+
self._sweep_documents()
|
|
362
|
+
raise
|
|
363
|
+
if not writes:
|
|
364
|
+
tx.discard()
|
|
365
|
+
self._sweep_documents()
|
|
366
|
+
return result
|
|
367
|
+
try:
|
|
368
|
+
seq = await self._storage.commit(writes, without_abort_signal(context))
|
|
369
|
+
except BaseException:
|
|
370
|
+
tx.discard()
|
|
371
|
+
raise
|
|
372
|
+
try:
|
|
373
|
+
documents = tx.adopt(seq)
|
|
374
|
+
except BaseException as error:
|
|
375
|
+
self.fail(error)
|
|
376
|
+
raise
|
|
377
|
+
for document in documents:
|
|
378
|
+
self._touch_document(_record_address_id(document["record"]))
|
|
379
|
+
self._publish(seq, writes, documents, context)
|
|
380
|
+
self._sweep_documents()
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
async def read_on_line(self, job: Callable[[], Awaitable[Any]]) -> Any:
|
|
384
|
+
self.assert_usable()
|
|
385
|
+
return await self._read_on_line(job)
|
|
386
|
+
|
|
387
|
+
async def read_queued_before_close(self, job: Callable[[], Awaitable[Any]]) -> Any:
|
|
388
|
+
"""Run a read admitted before ``close()`` sealed admission; a later read rejects.
|
|
389
|
+
|
|
390
|
+
The Harness uses this for ``inspect()``: an inspection already queued on the Session line at
|
|
391
|
+
the seal still runs and reports ``scheduling: "closing"``, while one asked for after the seal
|
|
392
|
+
rejects like every other new operation.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
self._assert_healthy()
|
|
396
|
+
if self._closing is not None:
|
|
397
|
+
raise self._closed_error()
|
|
398
|
+
return await self._read_on_line(job)
|
|
399
|
+
|
|
400
|
+
async def _read_on_line(self, job: Callable[[], Awaitable[Any]]) -> Any:
|
|
401
|
+
async with self._line:
|
|
402
|
+
self._assert_healthy()
|
|
403
|
+
if self._closed.done():
|
|
404
|
+
raise self._closed_error()
|
|
405
|
+
return await job()
|
|
406
|
+
|
|
407
|
+
async def context(
|
|
408
|
+
self,
|
|
409
|
+
conversation_id: ConversationId,
|
|
410
|
+
context: Context,
|
|
411
|
+
at: EntryId | None = None,
|
|
412
|
+
) -> ContextView:
|
|
413
|
+
"""Return one conversation's committed transcript and derived model context.
|
|
414
|
+
|
|
415
|
+
With ``at``, the context as of that visible entry, as a fork at that entry starts with.
|
|
416
|
+
Bounds are captured on the Session line; the entries at or below them are immutable, so
|
|
417
|
+
derivation runs off the line.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
self.assert_usable()
|
|
421
|
+
return await read_context(self, self._storage, conversation_id, context, at)
|
|
422
|
+
|
|
423
|
+
def assert_usable(self) -> None:
|
|
424
|
+
self._assert_healthy()
|
|
425
|
+
if self._closing is not None:
|
|
426
|
+
raise self._closed_error()
|
|
427
|
+
|
|
428
|
+
def _closed_error(self) -> Exception:
|
|
429
|
+
"""The error a closed Session rejects with; a Harness names itself."""
|
|
430
|
+
|
|
431
|
+
return RuntimeError("Session is closed")
|
|
432
|
+
|
|
433
|
+
def _assert_healthy(self) -> None:
|
|
434
|
+
if self._failure is not None:
|
|
435
|
+
raise SessionFailed(self._failure)
|
|
436
|
+
|
|
437
|
+
async def close(self, context: Context) -> None:
|
|
438
|
+
self._begin_close()
|
|
439
|
+
await self._wait_close(context)
|
|
440
|
+
|
|
441
|
+
def _begin_close(self) -> None:
|
|
442
|
+
"""Seal admission and start the shutdown; a repeated close shares the same future."""
|
|
443
|
+
|
|
444
|
+
if self._closing is not None:
|
|
445
|
+
return
|
|
446
|
+
loop = asyncio.get_running_loop()
|
|
447
|
+
closing: asyncio.Future[None] = loop.create_future()
|
|
448
|
+
closing.add_done_callback(lambda future: future.exception())
|
|
449
|
+
self._closing = closing
|
|
450
|
+
self._notify_close()
|
|
451
|
+
try:
|
|
452
|
+
loop.create_task(self._run_close())
|
|
453
|
+
except BaseException:
|
|
454
|
+
self._closing = None
|
|
455
|
+
raise
|
|
456
|
+
|
|
457
|
+
async def _wait_close(self, context: Context) -> None:
|
|
458
|
+
assert self._closing is not None
|
|
459
|
+
await _await_with_context(self._closing, context)
|
|
460
|
+
|
|
461
|
+
async def _run_close(self) -> None:
|
|
462
|
+
assert self._closing is not None
|
|
463
|
+
error: BaseException | None = None
|
|
464
|
+
try:
|
|
465
|
+
try:
|
|
466
|
+
await self.before_close()
|
|
467
|
+
except Exception as close_error:
|
|
468
|
+
self.report(close_error)
|
|
469
|
+
async with self._line:
|
|
470
|
+
self._commit_listeners.clear()
|
|
471
|
+
self._internal_listeners.clear()
|
|
472
|
+
self._documents.clear()
|
|
473
|
+
self._idle_documents.clear()
|
|
474
|
+
self._attached_documents.clear()
|
|
475
|
+
for state in list(self._states):
|
|
476
|
+
state._close_session()
|
|
477
|
+
self._states.clear()
|
|
478
|
+
try:
|
|
479
|
+
await self._storage.close(without_abort_signal(BACKGROUND_CONTEXT))
|
|
480
|
+
except BaseException as close_error:
|
|
481
|
+
error = close_error
|
|
482
|
+
if self._failure is None:
|
|
483
|
+
self._failure = close_error
|
|
484
|
+
self.report(close_error)
|
|
485
|
+
raise
|
|
486
|
+
except BaseException as caught:
|
|
487
|
+
error = caught
|
|
488
|
+
finally:
|
|
489
|
+
if not self._closed.done():
|
|
490
|
+
if self._failure is None:
|
|
491
|
+
self._closed.set_result({"reason": "closed"})
|
|
492
|
+
else:
|
|
493
|
+
self._closed.set_result({"reason": "failed", "error": self._failure})
|
|
494
|
+
if not self._closing.done():
|
|
495
|
+
if error is None:
|
|
496
|
+
self._closing.set_result(None)
|
|
497
|
+
else:
|
|
498
|
+
self._closing.set_exception(error)
|
|
499
|
+
|
|
500
|
+
def _notify_close(self) -> None:
|
|
501
|
+
self._cancel_document_sweep()
|
|
502
|
+
for task in list(self._sweep_tasks):
|
|
503
|
+
task.cancel()
|
|
504
|
+
for state in list(self._states):
|
|
505
|
+
state._close_session()
|
|
506
|
+
listeners = self._close_listeners
|
|
507
|
+
self._close_listeners = []
|
|
508
|
+
for listener in listeners:
|
|
509
|
+
contained(listener, self.report)
|
|
510
|
+
watches = self._watches
|
|
511
|
+
self._watches = []
|
|
512
|
+
for entry in watches:
|
|
513
|
+
entry.watch.close_session(self._failure)
|
|
514
|
+
|
|
515
|
+
def subscribe_commits(
|
|
516
|
+
self,
|
|
517
|
+
listener: Callable[[CommitPublication, Context], Any],
|
|
518
|
+
) -> Callable[[], None]:
|
|
519
|
+
self.assert_usable()
|
|
520
|
+
self._commit_listeners.append(listener)
|
|
521
|
+
return lambda: self._commit_listeners.remove(listener) if listener in self._commit_listeners else None
|
|
522
|
+
|
|
523
|
+
def observe_commits(
|
|
524
|
+
self,
|
|
525
|
+
listener: Callable[[CommitPublication, Context], Any],
|
|
526
|
+
) -> Callable[[], None]:
|
|
527
|
+
"""Internal: a commit listener whose state follows storage; a throw fails the Session."""
|
|
528
|
+
|
|
529
|
+
self.assert_usable()
|
|
530
|
+
self._internal_listeners.append(listener)
|
|
531
|
+
return (
|
|
532
|
+
lambda: self._internal_listeners.remove(listener)
|
|
533
|
+
if listener in self._internal_listeners
|
|
534
|
+
else None
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def subscribe_close(self, listener: Callable[[], Any]) -> Callable[[], None]:
|
|
538
|
+
self.assert_usable()
|
|
539
|
+
self._close_listeners.append(listener)
|
|
540
|
+
return lambda: self._close_listeners.remove(listener) if listener in self._close_listeners else None
|
|
541
|
+
|
|
542
|
+
def _publish(
|
|
543
|
+
self,
|
|
544
|
+
seq: Seq,
|
|
545
|
+
writes: list[StorageWrite],
|
|
546
|
+
documents: list[DocumentCommitChange],
|
|
547
|
+
context: Context,
|
|
548
|
+
) -> None:
|
|
549
|
+
if (
|
|
550
|
+
not self._commit_listeners
|
|
551
|
+
and not self._internal_listeners
|
|
552
|
+
and not self._states
|
|
553
|
+
and not self._watches
|
|
554
|
+
):
|
|
555
|
+
return
|
|
556
|
+
changes: list[CommitChange] = [
|
|
557
|
+
cast(CommitChange, write)
|
|
558
|
+
for write in writes
|
|
559
|
+
if write["type"] in ("conversation", "entry", "task", "submission")
|
|
560
|
+
]
|
|
561
|
+
changes.extend(documents)
|
|
562
|
+
publication: CommitPublication = {"seq": seq, "changes": changes}
|
|
563
|
+
for state in list(self._states):
|
|
564
|
+
for change in documents:
|
|
565
|
+
if change["type"] != "document" or change["record"]["id"] != state._record_id:
|
|
566
|
+
continue
|
|
567
|
+
ops, version = observed_operations(state._observed_version, change)
|
|
568
|
+
# A migration-only base changes nothing for an observer of the new version.
|
|
569
|
+
if not ops:
|
|
570
|
+
continue
|
|
571
|
+
state._observed_version = version
|
|
572
|
+
# A retired incarnation never changes again: its observers pin nothing.
|
|
573
|
+
if change["value"] is None:
|
|
574
|
+
state._release_pin()
|
|
575
|
+
state._advance(change["value"], context)
|
|
576
|
+
for entry in list(self._watches):
|
|
577
|
+
for change in documents:
|
|
578
|
+
if change["type"] != "document" or change["record"]["id"] != entry.record_id:
|
|
579
|
+
continue
|
|
580
|
+
if change["value"] is None:
|
|
581
|
+
entry.release()
|
|
582
|
+
entry.watch.advance(change, context)
|
|
583
|
+
for listener in list(self._internal_listeners):
|
|
584
|
+
contained(partial(listener, publication, context), self.fail)
|
|
585
|
+
for listener in list(self._commit_listeners):
|
|
586
|
+
contained(partial(listener, publication, context), self.report)
|
|
587
|
+
|
|
588
|
+
# ── Document cache ─────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
def _cached(self, address_id: str) -> LoadedDocument | None:
|
|
591
|
+
cached = self._documents.get(address_id)
|
|
592
|
+
if cached is not None:
|
|
593
|
+
self._touch_document(address_id)
|
|
594
|
+
return cached
|
|
595
|
+
|
|
596
|
+
def _install(self, document: LoadedDocument) -> None:
|
|
597
|
+
self._documents[document.address_id] = document
|
|
598
|
+
self._touch_document(document.address_id)
|
|
599
|
+
|
|
600
|
+
def _evict(self, address_id: str, record_id: DocumentId) -> None:
|
|
601
|
+
cached = self._documents.get(address_id)
|
|
602
|
+
if cached is not None and cached.record["id"] == record_id:
|
|
603
|
+
self._drop_document(address_id)
|
|
604
|
+
|
|
605
|
+
async def _load_document(
|
|
606
|
+
self,
|
|
607
|
+
definition: DocDefinition,
|
|
608
|
+
address_id: str,
|
|
609
|
+
address: DocumentAddress,
|
|
610
|
+
context: Context,
|
|
611
|
+
) -> LoadedDocument | None:
|
|
612
|
+
cached = self._documents.get(address_id)
|
|
613
|
+
if cached is not None and cached.value_version == definition.version:
|
|
614
|
+
self._touch_document(address_id)
|
|
615
|
+
return cached
|
|
616
|
+
if cached is not None:
|
|
617
|
+
self._drop_document(address_id)
|
|
618
|
+
record = await self._storage.find_document(address, "current", context)
|
|
619
|
+
if record is None:
|
|
620
|
+
return None
|
|
621
|
+
stored = await self._storage.document(record["id"], "current", context)
|
|
622
|
+
if stored is None:
|
|
623
|
+
raise RuntimeError(f"Current document {record['id']} ({record['kind']}) cannot be read")
|
|
624
|
+
value = materialize_document(definition, stored)
|
|
625
|
+
loaded = LoadedDocument(
|
|
626
|
+
address_id=address_id,
|
|
627
|
+
record=stored["record"],
|
|
628
|
+
stored_version=stored["version"],
|
|
629
|
+
value_version=definition.version,
|
|
630
|
+
deltas_since_base=stored["deltasSinceBase"],
|
|
631
|
+
value=value,
|
|
632
|
+
)
|
|
633
|
+
self._install(loaded)
|
|
634
|
+
return loaded
|
|
635
|
+
|
|
636
|
+
def _detach_state(self, state: DocumentState) -> None:
|
|
637
|
+
if state in self._states:
|
|
638
|
+
self._states.remove(state)
|
|
639
|
+
|
|
640
|
+
async def unload_documents(self) -> None:
|
|
641
|
+
"""Drop every loaded document value; later access cold-loads from Storage."""
|
|
642
|
+
|
|
643
|
+
async with self._line:
|
|
644
|
+
self._documents.clear()
|
|
645
|
+
self._idle_documents.clear()
|
|
646
|
+
|
|
647
|
+
# ── Document reads and observation ─────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
async def _document_on_line(self, token: AnyDocToken, *args: Any) -> LoadedDocument | None:
|
|
650
|
+
"""Load and validate one document while the caller holds the Session line.
|
|
651
|
+
|
|
652
|
+
Internal: conversation-view mounts capture their documents in the same line job as their
|
|
653
|
+
entries, so no commit can land between the two reads.
|
|
654
|
+
"""
|
|
655
|
+
|
|
656
|
+
definition = token.definition
|
|
657
|
+
resolved = resolve_address(definition, args)
|
|
658
|
+
context = args[resolved.next_argument]
|
|
659
|
+
loaded = await self._load_document(definition, resolved.id, resolved.address, context)
|
|
660
|
+
if loaded is not None:
|
|
661
|
+
_validate_document(definition, loaded)
|
|
662
|
+
return loaded
|
|
663
|
+
|
|
664
|
+
async def snapshot(self, token: AnyDocToken, *args: Any) -> JsonObject | None:
|
|
665
|
+
self.assert_usable()
|
|
666
|
+
definition = token.definition
|
|
667
|
+
resolved = resolve_address(definition, args)
|
|
668
|
+
context = args[resolved.next_argument]
|
|
669
|
+
# A cached document serves its committed revision without waiting for an in-flight commit,
|
|
670
|
+
# which holds the mutation line until Storage settles.
|
|
671
|
+
cached = self._documents.get(resolved.id)
|
|
672
|
+
if cached is not None and cached.value_version == definition.version:
|
|
673
|
+
self._touch_document(resolved.id)
|
|
674
|
+
_validate_document(definition, cached)
|
|
675
|
+
return cached.value
|
|
676
|
+
async with self._line:
|
|
677
|
+
self._assert_healthy()
|
|
678
|
+
loaded = await self._load_document(definition, resolved.id, resolved.address, context)
|
|
679
|
+
if loaded is None:
|
|
680
|
+
return None
|
|
681
|
+
_validate_document(definition, loaded)
|
|
682
|
+
return loaded.value
|
|
683
|
+
|
|
684
|
+
async def document_state(self, token: AnyDocToken, *args: Any) -> DocumentState | None:
|
|
685
|
+
self.assert_usable()
|
|
686
|
+
definition = token.definition
|
|
687
|
+
resolved = resolve_address(definition, args)
|
|
688
|
+
context = args[resolved.next_argument]
|
|
689
|
+
async with self._line:
|
|
690
|
+
self._assert_healthy()
|
|
691
|
+
loaded = await self._load_document(definition, resolved.id, resolved.address, context)
|
|
692
|
+
if loaded is None:
|
|
693
|
+
return None
|
|
694
|
+
_validate_document(definition, loaded)
|
|
695
|
+
# Close seals admission while the load was underway: no observer, and so no pin, remains.
|
|
696
|
+
self.assert_usable()
|
|
697
|
+
release = self._pin_document(loaded.address_id, loaded.record["id"])
|
|
698
|
+
state = DocumentState(
|
|
699
|
+
self, loaded.record["id"], loaded.value, loaded.value_version, release
|
|
700
|
+
)
|
|
701
|
+
self._states.append(state)
|
|
702
|
+
return state
|
|
703
|
+
|
|
704
|
+
async def watch_doc(self, token: AnyDocToken, *args: Any) -> CommittedWatch | None:
|
|
705
|
+
self.assert_usable()
|
|
706
|
+
definition = token.definition
|
|
707
|
+
resolved = resolve_address(definition, args)
|
|
708
|
+
context = args[resolved.next_argument]
|
|
709
|
+
signal = context.abort_signal
|
|
710
|
+
cancelled = signal is not None and signal.aborted
|
|
711
|
+
remove_listener: Callable[[], None] | None = None
|
|
712
|
+
if signal is not None:
|
|
713
|
+
|
|
714
|
+
def mark_cancelled() -> None:
|
|
715
|
+
nonlocal cancelled
|
|
716
|
+
cancelled = True
|
|
717
|
+
|
|
718
|
+
remove_listener = signal.add_listener(mark_cancelled)
|
|
719
|
+
|
|
720
|
+
def raise_if_cancelled() -> None:
|
|
721
|
+
if cancelled:
|
|
722
|
+
assert signal is not None
|
|
723
|
+
signal.raise_if_aborted()
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
async with self._line:
|
|
727
|
+
self._assert_healthy()
|
|
728
|
+
raise_if_cancelled()
|
|
729
|
+
loaded = await self._load_document(
|
|
730
|
+
definition, resolved.id, resolved.address, context
|
|
731
|
+
)
|
|
732
|
+
raise_if_cancelled()
|
|
733
|
+
if loaded is None:
|
|
734
|
+
return None
|
|
735
|
+
watch = self._attach_watch(definition, loaded)
|
|
736
|
+
if cancelled:
|
|
737
|
+
watch.cancel()
|
|
738
|
+
raise_if_cancelled()
|
|
739
|
+
if signal is not None:
|
|
740
|
+
watch.observe_cancellation(signal)
|
|
741
|
+
return watch
|
|
742
|
+
finally:
|
|
743
|
+
if remove_listener is not None:
|
|
744
|
+
remove_listener()
|
|
745
|
+
|
|
746
|
+
def _attach_watch(self, definition: DocDefinition, loaded: LoadedDocument) -> CommittedWatch:
|
|
747
|
+
_validate_document(definition, loaded)
|
|
748
|
+
# Close seals admission while the load was underway: no watch, and so no pin, remains.
|
|
749
|
+
self.assert_usable()
|
|
750
|
+
release = self._pin_document(loaded.address_id, loaded.record["id"])
|
|
751
|
+
|
|
752
|
+
def detach() -> None:
|
|
753
|
+
self._watches = [entry for entry in self._watches if entry.watch is not watch]
|
|
754
|
+
release()
|
|
755
|
+
|
|
756
|
+
watch = CommittedWatch(
|
|
757
|
+
cast(ObservedDocumentValue, loaded.value),
|
|
758
|
+
detach,
|
|
759
|
+
self.report,
|
|
760
|
+
loaded.value_version,
|
|
761
|
+
)
|
|
762
|
+
self._watches.append(_WatchAttachment(loaded.record["id"], watch, release))
|
|
763
|
+
return watch
|
|
764
|
+
|
|
765
|
+
async def snapshot_as_of(self, token: AnyDocToken, *args: Any) -> JsonObject | None:
|
|
766
|
+
self.assert_usable()
|
|
767
|
+
definition = token.definition
|
|
768
|
+
resolved = resolve_address(definition, args)
|
|
769
|
+
if resolved.address["scope"]["kind"] != "conversation":
|
|
770
|
+
raise TypeError("Session.snapshot_as_of() requires a conversation document")
|
|
771
|
+
if definition.history != "rewindable":
|
|
772
|
+
raise TypeError("Session.snapshot_as_of() requires a rewindable conversation document")
|
|
773
|
+
conversation_id = resolved.address["scope"]["conversationId"]
|
|
774
|
+
at_value = args[resolved.next_argument]
|
|
775
|
+
if not isinstance(at_value, int) or isinstance(at_value, bool) or at_value < 1:
|
|
776
|
+
raise TypeError("Session.snapshot_as_of() requires an entry ID")
|
|
777
|
+
context = args[resolved.next_argument + 1]
|
|
778
|
+
async with self._line:
|
|
779
|
+
self._assert_healthy()
|
|
780
|
+
stored_entry = await self._storage.entry(conversation_id, EntryId(at_value), context)
|
|
781
|
+
if stored_entry is None:
|
|
782
|
+
raise ValueError(f"Entry {at_value} is not visible from conversation {conversation_id}")
|
|
783
|
+
address = dict(resolved.address)
|
|
784
|
+
address["scope"] = {
|
|
785
|
+
"kind": "conversation",
|
|
786
|
+
"conversationId": stored_entry["entry"]["conversationId"],
|
|
787
|
+
}
|
|
788
|
+
record = await self._storage.find_document(
|
|
789
|
+
cast(DocumentAddress, address), stored_entry["commitSeq"], context
|
|
790
|
+
)
|
|
791
|
+
if record is None:
|
|
792
|
+
return None
|
|
793
|
+
stored = await self._storage.document(record["id"], stored_entry["commitSeq"], context)
|
|
794
|
+
if stored is None:
|
|
795
|
+
raise ValueError(f"Historical document {record['id']} ({record['kind']}) cannot be read")
|
|
796
|
+
return materialize_document(definition, stored)
|
|
797
|
+
|
|
798
|
+
@property
|
|
799
|
+
def document_cache_sizes(self) -> DocumentCacheSizes:
|
|
800
|
+
"""Internal, for tests: cached, idle, and pinned documents, and sweep jobs so far."""
|
|
801
|
+
|
|
802
|
+
return DocumentCacheSizes(
|
|
803
|
+
cached=len(self._documents),
|
|
804
|
+
idle=len(self._idle_documents),
|
|
805
|
+
attached=len(self._attached_documents),
|
|
806
|
+
sweeps=self._sweep_jobs,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
def _touch_document(self, address_id: str) -> None:
|
|
810
|
+
"""Note a use: an unattached document becomes the newest idle one and schedules a sweep."""
|
|
811
|
+
|
|
812
|
+
cached = self._documents.get(address_id)
|
|
813
|
+
if cached is None or cached.record["id"] in self._attached_documents:
|
|
814
|
+
return
|
|
815
|
+
now = self._clock()
|
|
816
|
+
since = self._idle_documents.get(address_id)
|
|
817
|
+
# A hot document moves to the back at most once per granule, a tenth of the retention up to
|
|
818
|
+
# 1 s: it may then leave a granule early, which costs a reload, while a commit-heavy
|
|
819
|
+
# workload skips the reordering.
|
|
820
|
+
if since is not None and now - since < min(1_000, self._document_retention_ms() / 10):
|
|
821
|
+
return
|
|
822
|
+
self._idle_documents.pop(address_id, None)
|
|
823
|
+
self._idle_documents[address_id] = now
|
|
824
|
+
# A pending sweep for the oldest document comes first unless the retention was shortened.
|
|
825
|
+
if self._document_sweep is None or self._document_sweep.at > now + self._document_retention_ms():
|
|
826
|
+
self._schedule_document_sweep()
|
|
827
|
+
|
|
828
|
+
def _drop_document(self, address_id: str) -> None:
|
|
829
|
+
self._documents.pop(address_id, None)
|
|
830
|
+
self._idle_documents.pop(address_id, None)
|
|
831
|
+
|
|
832
|
+
def _pin_document(self, address_id: str, record_id: DocumentId) -> Callable[[], None]:
|
|
833
|
+
"""Pin one incarnation for one observer; the returned release unpins it once."""
|
|
834
|
+
|
|
835
|
+
self._attached_documents[record_id] = self._attached_documents.get(record_id, 0) + 1
|
|
836
|
+
self._idle_documents.pop(address_id, None)
|
|
837
|
+
pinned = True
|
|
838
|
+
|
|
839
|
+
def release() -> None:
|
|
840
|
+
nonlocal pinned
|
|
841
|
+
if not pinned:
|
|
842
|
+
return
|
|
843
|
+
pinned = False
|
|
844
|
+
count = self._attached_documents.get(record_id, 1) - 1
|
|
845
|
+
if count > 0:
|
|
846
|
+
self._attached_documents[record_id] = count
|
|
847
|
+
return
|
|
848
|
+
self._attached_documents.pop(record_id, None)
|
|
849
|
+
cached = self._documents.get(address_id)
|
|
850
|
+
if cached is not None and cached.record["id"] == record_id:
|
|
851
|
+
self._touch_document(address_id)
|
|
852
|
+
|
|
853
|
+
return release
|
|
854
|
+
|
|
855
|
+
def _schedule_document_sweep(self) -> None:
|
|
856
|
+
"""Schedule the eviction of the oldest idle document, or right away when retention is 0."""
|
|
857
|
+
|
|
858
|
+
if self._closing is not None or not self._idle_documents:
|
|
859
|
+
return
|
|
860
|
+
# A synchronous observer method (`stop()`, `dispose()`) may run without a loop: then only
|
|
861
|
+
# the next commit evicts, as where timers cannot be unreferenced.
|
|
862
|
+
try:
|
|
863
|
+
loop = asyncio.get_running_loop()
|
|
864
|
+
except RuntimeError:
|
|
865
|
+
return
|
|
866
|
+
at = next(iter(self._idle_documents.values())) + self._document_retention_ms()
|
|
867
|
+
if self._document_sweep is not None and self._document_sweep.at <= at:
|
|
868
|
+
return
|
|
869
|
+
if self._document_sweep is not None and self._document_sweep.timer is not None:
|
|
870
|
+
self._document_sweep.timer.cancel()
|
|
871
|
+
sweep = _DocumentSweep(at=at)
|
|
872
|
+
delay = at - self._clock()
|
|
873
|
+
|
|
874
|
+
def run() -> None:
|
|
875
|
+
self._sweep_jobs += 1
|
|
876
|
+
self._queue_sweep_job(sweep)
|
|
877
|
+
|
|
878
|
+
# The pending sweep stays set until its job runs, so touches while the line is busy queue
|
|
879
|
+
# no more than one.
|
|
880
|
+
self._document_sweep = sweep
|
|
881
|
+
if delay <= 0:
|
|
882
|
+
loop.call_soon(run)
|
|
883
|
+
return
|
|
884
|
+
if self._document_timers_unavailable:
|
|
885
|
+
self._document_sweep = None
|
|
886
|
+
return
|
|
887
|
+
timer = _schedule_document_timer(min(delay, _MAX_TIMER_DELAY_MS), run)
|
|
888
|
+
if timer is None:
|
|
889
|
+
self._document_timers_unavailable = True
|
|
890
|
+
self._document_sweep = None
|
|
891
|
+
return
|
|
892
|
+
sweep.timer = timer
|
|
893
|
+
|
|
894
|
+
def _cancel_document_sweep(self) -> None:
|
|
895
|
+
sweep = self._document_sweep
|
|
896
|
+
self._document_sweep = None
|
|
897
|
+
if sweep is not None and sweep.timer is not None:
|
|
898
|
+
sweep.timer.cancel()
|
|
899
|
+
|
|
900
|
+
def _queue_sweep_job(self, sweep: _DocumentSweep) -> None:
|
|
901
|
+
try:
|
|
902
|
+
loop = asyncio.get_running_loop()
|
|
903
|
+
except RuntimeError:
|
|
904
|
+
return
|
|
905
|
+
task = loop.create_task(self._run_sweep_job(sweep))
|
|
906
|
+
self._sweep_tasks.add(task)
|
|
907
|
+
task.add_done_callback(self._sweep_tasks.discard)
|
|
908
|
+
|
|
909
|
+
async def _run_sweep_job(self, sweep: _DocumentSweep) -> None:
|
|
910
|
+
try:
|
|
911
|
+
async with self._line:
|
|
912
|
+
if self._document_sweep is sweep:
|
|
913
|
+
self._document_sweep = None
|
|
914
|
+
if self._closing is None:
|
|
915
|
+
self._sweep_documents()
|
|
916
|
+
except asyncio.CancelledError:
|
|
917
|
+
raise
|
|
918
|
+
except Exception:
|
|
919
|
+
pass
|
|
920
|
+
|
|
921
|
+
def _sweep_documents(self) -> None:
|
|
922
|
+
"""On the line: evict the idle documents whose retention passed, then schedule the next."""
|
|
923
|
+
|
|
924
|
+
expired = self._clock() - self._document_retention_ms()
|
|
925
|
+
for cached_id, since in list(self._idle_documents.items()):
|
|
926
|
+
if since > expired:
|
|
927
|
+
break
|
|
928
|
+
self._drop_document(cached_id)
|
|
929
|
+
self._schedule_document_sweep()
|
|
930
|
+
|
|
931
|
+
# ── Host callbacks ─────────────────────────────────────────────────────
|
|
932
|
+
|
|
933
|
+
async def conversation_created(self, tx: Transaction, record: ConversationRecord) -> None:
|
|
934
|
+
"""A plain Session stages nothing extra for a new conversation."""
|
|
935
|
+
|
|
936
|
+
async def before_close(self) -> None:
|
|
937
|
+
"""Run after close seals admission and before the line closes Storage; a Harness joins tasks here."""
|
|
938
|
+
|
|
939
|
+
# ── Internal ───────────────────────────────────────────────────────────
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
class GuardedStorage:
|
|
944
|
+
"""The Session's Storage behind its failure guard."""
|
|
945
|
+
|
|
946
|
+
def __init__(self, storage: Storage, session: SessionImpl) -> None:
|
|
947
|
+
self._storage = storage
|
|
948
|
+
self._session = session
|
|
949
|
+
self._underway: set[asyncio.Future[Any]] = set()
|
|
950
|
+
self._closing = False
|
|
951
|
+
|
|
952
|
+
def _assert_healthy(self) -> None:
|
|
953
|
+
if self._session.failure is not None:
|
|
954
|
+
raise SessionFailed(self._session.failure)
|
|
955
|
+
|
|
956
|
+
async def _call(self, read: Context | None, run: Callable[[], Awaitable[Any]]) -> Any:
|
|
957
|
+
self._assert_healthy()
|
|
958
|
+
if self._closing:
|
|
959
|
+
raise RuntimeError("Session is closed")
|
|
960
|
+
call = asyncio.ensure_future(run())
|
|
961
|
+
self._underway.add(call)
|
|
962
|
+
try:
|
|
963
|
+
result = await call
|
|
964
|
+
except BaseException as error:
|
|
965
|
+
if not is_failure(error):
|
|
966
|
+
raise
|
|
967
|
+
self._assert_healthy()
|
|
968
|
+
exempt = read is not None and (
|
|
969
|
+
isinstance(error, StorageRequestError)
|
|
970
|
+
or (read.abort_signal is not None and read.abort_signal.aborted)
|
|
971
|
+
)
|
|
972
|
+
if not exempt:
|
|
973
|
+
self._session.fail(error)
|
|
974
|
+
raise
|
|
975
|
+
finally:
|
|
976
|
+
self._underway.discard(call)
|
|
977
|
+
self._assert_healthy()
|
|
978
|
+
return result
|
|
979
|
+
|
|
980
|
+
async def commit(self, writes: Any, context: Context) -> Seq:
|
|
981
|
+
return cast(Seq, await self._call(None, lambda: self._storage.commit(writes, context)))
|
|
982
|
+
|
|
983
|
+
async def mint_id(self) -> int:
|
|
984
|
+
return cast(int, await self._call(None, lambda: self._storage.mint_id()))
|
|
985
|
+
|
|
986
|
+
async def conversation(self, id: ConversationId, context: Context) -> ConversationRecord | None:
|
|
987
|
+
return cast(
|
|
988
|
+
ConversationRecord | None,
|
|
989
|
+
await self._call(context, lambda: self._storage.conversation(id, context)),
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
async def scan_conversations(
|
|
993
|
+
self, query: ConversationQuery, limit: int, cursor: Cursor | None, context: Context
|
|
994
|
+
) -> Page[ConversationRecord, Cursor]:
|
|
995
|
+
return cast(
|
|
996
|
+
Page[ConversationRecord, Cursor],
|
|
997
|
+
await self._call(
|
|
998
|
+
context,
|
|
999
|
+
lambda: self._storage.scan_conversations(query, limit, cursor, context),
|
|
1000
|
+
),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
async def entry(self, *args: Any, context: Context | None = None, **kwargs: Any) -> EntryLookup | None:
|
|
1004
|
+
if context is None and len(args) == 3:
|
|
1005
|
+
resolved: Context = args[2]
|
|
1006
|
+
return cast(
|
|
1007
|
+
EntryLookup | None,
|
|
1008
|
+
await self._call(resolved, lambda: self._storage.entry(args[0], args[1], resolved)),
|
|
1009
|
+
)
|
|
1010
|
+
if context is None and len(args) == 2 and isinstance(args[1], Context):
|
|
1011
|
+
resolved = args[1]
|
|
1012
|
+
return cast(
|
|
1013
|
+
EntryLookup | None,
|
|
1014
|
+
await self._call(resolved, lambda: self._storage.entry(args[0], resolved)),
|
|
1015
|
+
)
|
|
1016
|
+
if context is None:
|
|
1017
|
+
raise TypeError("Storage.entry() requires a context")
|
|
1018
|
+
return cast(
|
|
1019
|
+
EntryLookup | None,
|
|
1020
|
+
await self._call(context, lambda: self._storage.entry(args[0], context)),
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
async def find_latest_head_marker(
|
|
1024
|
+
self,
|
|
1025
|
+
conversation_id: ConversationId,
|
|
1026
|
+
at_or_before_entry_id: EntryId | None,
|
|
1027
|
+
context: Context,
|
|
1028
|
+
) -> EntryWithHead | None:
|
|
1029
|
+
return cast(
|
|
1030
|
+
EntryWithHead | None,
|
|
1031
|
+
await self._call(
|
|
1032
|
+
context,
|
|
1033
|
+
lambda: self._storage.find_latest_head_marker(
|
|
1034
|
+
conversation_id, at_or_before_entry_id, context
|
|
1035
|
+
),
|
|
1036
|
+
),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
async def scan_entries(
|
|
1040
|
+
self, query: EntryQuery, limit: int, cursor: Cursor | None, context: Context
|
|
1041
|
+
) -> Page[EntryRecord, Cursor]:
|
|
1042
|
+
return cast(
|
|
1043
|
+
Page[EntryRecord, Cursor],
|
|
1044
|
+
await self._call(context, lambda: self._storage.scan_entries(query, limit, cursor, context)),
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
async def task(self, id: TaskId, context: Context) -> TaskRecord | None:
|
|
1048
|
+
return cast(TaskRecord | None, await self._call(context, lambda: self._storage.task(id, context)))
|
|
1049
|
+
|
|
1050
|
+
async def scan_tasks(
|
|
1051
|
+
self, query: TaskQuery, limit: int, cursor: Cursor | None, context: Context
|
|
1052
|
+
) -> Page[TaskRecord, Cursor]:
|
|
1053
|
+
return cast(
|
|
1054
|
+
Page[TaskRecord, Cursor],
|
|
1055
|
+
await self._call(context, lambda: self._storage.scan_tasks(query, limit, cursor, context)),
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
async def submission(self, id: Any, context: Context) -> SubmissionRecord | None:
|
|
1059
|
+
return cast(
|
|
1060
|
+
SubmissionRecord | None,
|
|
1061
|
+
await self._call(context, lambda: self._storage.submission(id, context)),
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
async def scan_submissions(
|
|
1065
|
+
self, query: SubmissionQuery, limit: int, cursor: Cursor | None, context: Context
|
|
1066
|
+
) -> Page[SubmissionRecord, Cursor]:
|
|
1067
|
+
return cast(
|
|
1068
|
+
Page[SubmissionRecord, Cursor],
|
|
1069
|
+
await self._call(context, lambda: self._storage.scan_submissions(query, limit, cursor, context)),
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
async def submission_by_request(
|
|
1073
|
+
self, conversation_id: ConversationId, request_id: str, context: Context
|
|
1074
|
+
) -> SubmissionRecord | None:
|
|
1075
|
+
return cast(
|
|
1076
|
+
SubmissionRecord | None,
|
|
1077
|
+
await self._call(
|
|
1078
|
+
context,
|
|
1079
|
+
lambda: self._storage.submission_by_request(conversation_id, request_id, context),
|
|
1080
|
+
),
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
async def find_document(
|
|
1084
|
+
self, address: DocumentAddress, at: DocumentPoint, context: Context
|
|
1085
|
+
) -> DocumentRecord | None:
|
|
1086
|
+
return cast(
|
|
1087
|
+
DocumentRecord | None,
|
|
1088
|
+
await self._call(context, lambda: self._storage.find_document(address, at, context)),
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
async def document(self, id: DocumentId, at: DocumentPoint, context: Context) -> StoredDocument | None:
|
|
1092
|
+
return cast(
|
|
1093
|
+
StoredDocument | None,
|
|
1094
|
+
await self._call(context, lambda: self._storage.document(id, at, context)),
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
async def scan_documents(
|
|
1098
|
+
self, query: DocumentQuery, limit: int, cursor: Cursor | None, context: Context
|
|
1099
|
+
) -> Page[DocumentRecord, Cursor]:
|
|
1100
|
+
return cast(
|
|
1101
|
+
Page[DocumentRecord, Cursor],
|
|
1102
|
+
await self._call(context, lambda: self._storage.scan_documents(query, limit, cursor, context)),
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
async def close(self, context: Context) -> None:
|
|
1106
|
+
self._closing = True
|
|
1107
|
+
if self._underway:
|
|
1108
|
+
await asyncio.gather(*list(self._underway), return_exceptions=True)
|
|
1109
|
+
await self._storage.close(context)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _current_loop() -> asyncio.AbstractEventLoop:
|
|
1113
|
+
try:
|
|
1114
|
+
return asyncio.get_running_loop()
|
|
1115
|
+
except RuntimeError:
|
|
1116
|
+
pass
|
|
1117
|
+
try:
|
|
1118
|
+
return asyncio.get_event_loop()
|
|
1119
|
+
except RuntimeError:
|
|
1120
|
+
return asyncio.new_event_loop()
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
async def _await_with_context(awaitable: Awaitable[Any], context: Context) -> Any:
|
|
1124
|
+
signal = context.abort_signal
|
|
1125
|
+
if signal is None:
|
|
1126
|
+
return await awaitable
|
|
1127
|
+
if signal.aborted:
|
|
1128
|
+
raise _abort_error(signal)
|
|
1129
|
+
loop = asyncio.get_running_loop()
|
|
1130
|
+
waiter: asyncio.Future[None] = loop.create_future()
|
|
1131
|
+
|
|
1132
|
+
def abort() -> None:
|
|
1133
|
+
if not waiter.done():
|
|
1134
|
+
waiter.set_exception(_abort_error(signal))
|
|
1135
|
+
|
|
1136
|
+
remove = signal.add_listener(abort)
|
|
1137
|
+
if signal.aborted:
|
|
1138
|
+
abort()
|
|
1139
|
+
task: asyncio.Future[Any] = asyncio.ensure_future(awaitable)
|
|
1140
|
+
try:
|
|
1141
|
+
await asyncio.wait((task, waiter), return_when=asyncio.FIRST_COMPLETED)
|
|
1142
|
+
if task.done():
|
|
1143
|
+
return task.result()
|
|
1144
|
+
waiter.result()
|
|
1145
|
+
finally:
|
|
1146
|
+
remove()
|
|
1147
|
+
if not waiter.done():
|
|
1148
|
+
waiter.cancel()
|
|
1149
|
+
raise AssertionError("unreachable")
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def contained(callback: Callable[[], Any], on_error: Callable[[BaseException], None]) -> None:
|
|
1153
|
+
"""Run a host callback without failing its caller; a throw or rejection goes to ``on_error``."""
|
|
1154
|
+
|
|
1155
|
+
try:
|
|
1156
|
+
result = callback()
|
|
1157
|
+
if not hasattr(result, "__await__"):
|
|
1158
|
+
return
|
|
1159
|
+
future = asyncio.ensure_future(result)
|
|
1160
|
+
|
|
1161
|
+
def settle(done: asyncio.Future[Any]) -> None:
|
|
1162
|
+
if done.cancelled():
|
|
1163
|
+
return
|
|
1164
|
+
try:
|
|
1165
|
+
error = done.exception()
|
|
1166
|
+
except BaseException as caught:
|
|
1167
|
+
error = caught
|
|
1168
|
+
if error is not None:
|
|
1169
|
+
on_error(error)
|
|
1170
|
+
|
|
1171
|
+
future.add_done_callback(settle)
|
|
1172
|
+
except BaseException as error:
|
|
1173
|
+
on_error(error)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _abort_error(signal: Any) -> BaseException:
|
|
1177
|
+
reason = signal.reason
|
|
1178
|
+
if isinstance(reason, BaseException):
|
|
1179
|
+
return reason
|
|
1180
|
+
from ..context import AbortError
|
|
1181
|
+
|
|
1182
|
+
return AbortError(reason)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
__all__ = [
|
|
1186
|
+
"DocumentCacheSizes",
|
|
1187
|
+
"DocumentState",
|
|
1188
|
+
"SessionImpl",
|
|
1189
|
+
"contained",
|
|
1190
|
+
"create_session",
|
|
1191
|
+
"document_retention",
|
|
1192
|
+
"set_default_document_retention",
|
|
1193
|
+
]
|