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.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. 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
+ ]