@earendil-works/pi-durable 1.3.0 → 1.5.0
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.
- package/CHANGELOG.md +227 -0
- package/README.md +287 -82
- package/dist/codemode/codemode.d.ts +4 -4
- package/dist/codemode/codemode.d.ts.map +1 -1
- package/dist/codemode/codemode.js +21 -16
- package/dist/codemode/codemode.js.map +1 -1
- package/dist/codemode/models.d.ts.map +1 -1
- package/dist/codemode/models.js +6 -4
- package/dist/codemode/models.js.map +1 -1
- package/dist/documents.d.ts +1 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js.map +1 -1
- package/dist/env/index.d.ts +12 -10
- package/dist/env/index.d.ts.map +1 -1
- package/dist/env/index.js.map +1 -1
- package/dist/env/node-watch.d.ts +3 -3
- package/dist/env/node-watch.d.ts.map +1 -1
- package/dist/env/node-watch.js +18 -16
- package/dist/env/node-watch.js.map +1 -1
- package/dist/env/node.d.ts +2 -2
- package/dist/env/node.d.ts.map +1 -1
- package/dist/env/node.js +12 -4
- package/dist/env/node.js.map +1 -1
- package/dist/errors.d.ts +18 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +26 -3
- package/dist/errors.js.map +1 -1
- package/dist/harness/agent.d.ts +13 -2
- package/dist/harness/agent.d.ts.map +1 -1
- package/dist/harness/agent.js +48 -6
- package/dist/harness/agent.js.map +1 -1
- package/dist/harness/compaction.d.ts +3 -1
- package/dist/harness/compaction.d.ts.map +1 -1
- package/dist/harness/compaction.js +38 -6
- package/dist/harness/compaction.js.map +1 -1
- package/dist/harness/context.d.ts +23 -7
- package/dist/harness/context.d.ts.map +1 -1
- package/dist/harness/context.js +71 -25
- package/dist/harness/context.js.map +1 -1
- package/dist/harness/define.d.ts +7 -3
- package/dist/harness/define.d.ts.map +1 -1
- package/dist/harness/define.js +4 -0
- package/dist/harness/define.js.map +1 -1
- package/dist/harness/generation.d.ts +38 -5
- package/dist/harness/generation.d.ts.map +1 -1
- package/dist/harness/generation.js +497 -108
- package/dist/harness/generation.js.map +1 -1
- package/dist/harness/harness.d.ts +7 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +147 -57
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/inbox.d.ts +7 -3
- package/dist/harness/inbox.d.ts.map +1 -1
- package/dist/harness/inbox.js +7 -4
- package/dist/harness/inbox.js.map +1 -1
- package/dist/harness/json.d.ts +20 -4
- package/dist/harness/json.d.ts.map +1 -1
- package/dist/harness/json.js +23 -5
- package/dist/harness/json.js.map +1 -1
- package/dist/harness/live.d.ts +122 -16
- package/dist/harness/live.d.ts.map +1 -1
- package/dist/harness/live.js +93 -12
- package/dist/harness/live.js.map +1 -1
- package/dist/harness/partial-changes.d.ts +54 -0
- package/dist/harness/partial-changes.d.ts.map +1 -0
- package/dist/harness/partial-changes.js +162 -0
- package/dist/harness/partial-changes.js.map +1 -0
- package/dist/harness/prompt.d.ts +1 -1
- package/dist/harness/prompt.d.ts.map +1 -1
- package/dist/harness/prompt.js +1 -1
- package/dist/harness/prompt.js.map +1 -1
- package/dist/harness/registry-reader.d.ts +11 -0
- package/dist/harness/registry-reader.d.ts.map +1 -0
- package/dist/harness/registry-reader.js +25 -0
- package/dist/harness/registry-reader.js.map +1 -0
- package/dist/harness/scheduler.d.ts +86 -14
- package/dist/harness/scheduler.d.ts.map +1 -1
- package/dist/harness/scheduler.js +1459 -369
- package/dist/harness/scheduler.js.map +1 -1
- package/dist/harness/submissions.d.ts +23 -11
- package/dist/harness/submissions.d.ts.map +1 -1
- package/dist/harness/submissions.js +104 -36
- package/dist/harness/submissions.js.map +1 -1
- package/dist/harness/task-graph.d.ts +28 -8
- package/dist/harness/task-graph.d.ts.map +1 -1
- package/dist/harness/task-graph.js +85 -36
- package/dist/harness/task-graph.js.map +1 -1
- package/dist/harness/telemetry.d.ts +307 -0
- package/dist/harness/telemetry.d.ts.map +1 -0
- package/dist/harness/telemetry.js +136 -0
- package/dist/harness/telemetry.js.map +1 -0
- package/dist/harness/tool.d.ts +2 -0
- package/dist/harness/tool.d.ts.map +1 -1
- package/dist/harness/tool.js +223 -61
- package/dist/harness/tool.js.map +1 -1
- package/dist/harness/transcript.d.ts +72 -0
- package/dist/harness/transcript.d.ts.map +1 -0
- package/dist/harness/transcript.js +264 -0
- package/dist/harness/transcript.js.map +1 -0
- package/dist/harness/types.d.ts +302 -48
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/util.d.ts +7 -1
- package/dist/harness/util.d.ts.map +1 -1
- package/dist/harness/util.js +13 -0
- package/dist/harness/util.js.map +1 -1
- package/dist/harness/view.d.ts +43 -22
- package/dist/harness/view.d.ts.map +1 -1
- package/dist/harness/view.js +130 -71
- package/dist/harness/view.js.map +1 -1
- package/dist/index.d.ts +13 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/search.d.ts +19 -3
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +107 -44
- package/dist/search.js.map +1 -1
- package/dist/session/cached-storage.d.ts +71 -0
- package/dist/session/cached-storage.d.ts.map +1 -0
- package/dist/session/cached-storage.js +269 -0
- package/dist/session/cached-storage.js.map +1 -0
- package/dist/session/conversation-tails.d.ts +33 -0
- package/dist/session/conversation-tails.d.ts.map +1 -0
- package/dist/session/conversation-tails.js +101 -0
- package/dist/session/conversation-tails.js.map +1 -0
- package/dist/session/keys.d.ts +68 -0
- package/dist/session/keys.d.ts.map +1 -0
- package/dist/session/keys.js +161 -0
- package/dist/session/keys.js.map +1 -0
- package/dist/session/session.d.ts +181 -29
- package/dist/session/session.d.ts.map +1 -1
- package/dist/session/session.js +1073 -183
- package/dist/session/session.js.map +1 -1
- package/dist/session/transaction.d.ts +86 -4
- package/dist/session/transaction.d.ts.map +1 -1
- package/dist/session/transaction.js +250 -38
- package/dist/session/transaction.js.map +1 -1
- package/dist/session/writer.d.ts +74 -0
- package/dist/session/writer.d.ts.map +1 -0
- package/dist/session/writer.js +294 -0
- package/dist/session/writer.js.map +1 -0
- package/dist/storage/indexeddb.d.ts +68 -0
- package/dist/storage/indexeddb.d.ts.map +1 -0
- package/dist/storage/indexeddb.js +881 -0
- package/dist/storage/indexeddb.js.map +1 -0
- package/dist/storage/jsonl/storage.d.ts +30 -2
- package/dist/storage/jsonl/storage.d.ts.map +1 -1
- package/dist/storage/jsonl/storage.js +88 -24
- package/dist/storage/jsonl/storage.js.map +1 -1
- package/dist/storage/memory.d.ts +7 -2
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +29 -3
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite/cloudflare.d.ts +2 -51
- package/dist/storage/sqlite/cloudflare.d.ts.map +1 -1
- package/dist/storage/sqlite/cloudflare.js +2 -133
- package/dist/storage/sqlite/cloudflare.js.map +1 -1
- package/dist/storage/sqlite/database.d.ts +1 -36
- package/dist/storage/sqlite/database.d.ts.map +1 -1
- package/dist/storage/sqlite/database.js.map +1 -1
- package/dist/storage/sqlite/index.d.ts +1 -1
- package/dist/storage/sqlite/index.d.ts.map +1 -1
- package/dist/storage/sqlite/index.js.map +1 -1
- package/dist/storage/sqlite/migrations.d.ts +2 -5
- package/dist/storage/sqlite/migrations.d.ts.map +1 -1
- package/dist/storage/sqlite/migrations.js +28 -28
- package/dist/storage/sqlite/migrations.js.map +1 -1
- package/dist/storage/sqlite/node.d.ts +2 -37
- package/dist/storage/sqlite/node.d.ts.map +1 -1
- package/dist/storage/sqlite/node.js +3 -174
- package/dist/storage/sqlite/node.js.map +1 -1
- package/dist/storage/sqlite/schema.d.ts +1 -3
- package/dist/storage/sqlite/schema.d.ts.map +1 -1
- package/dist/storage/sqlite/schema.js +2 -9
- package/dist/storage/sqlite/schema.js.map +1 -1
- package/dist/storage/sqlite/storage.d.ts +15 -3
- package/dist/storage/sqlite/storage.d.ts.map +1 -1
- package/dist/storage/sqlite/storage.js +110 -36
- package/dist/storage/sqlite/storage.js.map +1 -1
- package/dist/storage/store/commit.d.ts +33 -0
- package/dist/storage/store/commit.d.ts.map +1 -0
- package/dist/storage/store/commit.js +286 -0
- package/dist/storage/store/commit.js.map +1 -0
- package/dist/storage/store/documents.d.ts +43 -0
- package/dist/storage/store/documents.d.ts.map +1 -0
- package/dist/storage/store/documents.js +102 -0
- package/dist/storage/store/documents.js.map +1 -0
- package/dist/storage/store/index.d.ts +2 -0
- package/dist/storage/store/index.d.ts.map +1 -0
- package/dist/storage/store/index.js +2 -0
- package/dist/storage/store/index.js.map +1 -0
- package/dist/storage/store/queries.d.ts +49 -0
- package/dist/storage/store/queries.d.ts.map +1 -0
- package/dist/storage/store/queries.js +93 -0
- package/dist/storage/store/queries.js.map +1 -0
- package/dist/storage/store/storage.d.ts +59 -0
- package/dist/storage/store/storage.d.ts.map +1 -0
- package/dist/storage/store/storage.js +438 -0
- package/dist/storage/store/storage.js.map +1 -0
- package/dist/storage/store/tables.d.ts +54 -0
- package/dist/storage/store/tables.d.ts.map +1 -0
- package/dist/storage/store/tables.js +108 -0
- package/dist/storage/store/tables.js.map +1 -0
- package/dist/testing/commit-writes.d.ts +5 -0
- package/dist/testing/commit-writes.d.ts.map +1 -0
- package/dist/testing/commit-writes.js +12 -0
- package/dist/testing/commit-writes.js.map +1 -0
- package/dist/testing/env-conformance.js +7 -7
- package/dist/testing/env-conformance.js.map +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/storage-benchmark.d.ts.map +1 -1
- package/dist/testing/storage-benchmark.js +18 -17
- package/dist/testing/storage-benchmark.js.map +1 -1
- package/dist/testing/storage-conformance.d.ts.map +1 -1
- package/dist/testing/storage-conformance.js +369 -67
- package/dist/testing/storage-conformance.js.map +1 -1
- package/dist/timers.d.ts +21 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +72 -0
- package/dist/timers.js.map +1 -0
- package/dist/tool-search/tool-search.d.ts +6 -12
- package/dist/tool-search/tool-search.d.ts.map +1 -1
- package/dist/tool-search/tool-search.js +7 -7
- package/dist/tool-search/tool-search.js.map +1 -1
- package/dist/tools/bash.d.ts +12 -12
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +16 -15
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +7 -7
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +9 -9
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/index.d.ts +32 -32
- package/dist/tools/read.d.ts +6 -6
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +19 -8
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/write.d.ts +4 -4
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js +5 -5
- package/dist/tools/write.js.map +1 -1
- package/dist/types.d.ts +331 -64
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +27 -10
- package/dist/harness/events.d.ts +0 -186
- package/dist/harness/events.d.ts.map +0 -1
- package/dist/harness/events.js +0 -373
- package/dist/harness/events.js.map +0 -1
- package/dist/session/observation.d.ts +0 -42
- package/dist/session/observation.d.ts.map +0 -1
- package/dist/session/observation.js +0 -281
- package/dist/session/observation.js.map +0 -1
package/dist/session/session.js
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Watch } from "@earendil-works/chord";
|
|
2
2
|
import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
|
|
3
|
-
import { track } from "@earendil-works/chord/delta";
|
|
3
|
+
import { PendingOps, track } from "@earendil-works/chord/delta";
|
|
4
4
|
import { addressId, checkRecordScope, checkRecordVersion, materializeDocument, resolveAddress, } from "../documents.js";
|
|
5
|
-
import { SessionFailed, StorageRequestError } from "../errors.js";
|
|
5
|
+
import { ReadOnlySession, SessionFailed, StorageRequestError } from "../errors.js";
|
|
6
6
|
import { idFromNumber } from "../ids.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { hostTimers } from "../timers.js";
|
|
8
|
+
import { CachedStorage, scopeOf } from "./cached-storage.js";
|
|
9
|
+
import { ConversationTails } from "./conversation-tails.js";
|
|
10
|
+
import { ALL_KEYS, appendKey, conversationKey, documentKey, GatedStorage, incarnationKey } from "./keys.js";
|
|
11
|
+
import { storeHeld, Transaction, } from "./transaction.js";
|
|
12
|
+
import { Writer } from "./writer.js";
|
|
13
|
+
const NO_HOLDS = [];
|
|
14
|
+
const NO_OPERATIONS = [];
|
|
15
|
+
const NO_KEYS = [];
|
|
9
16
|
/**
|
|
10
|
-
*
|
|
17
|
+
* Jobs admitted to the line whose outcome is not delivered yet, beyond which the line waits. A count, not bytes: what
|
|
18
|
+
* waits is bounded by what one commit can hold (tool output by its output limits), times the count.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_OUTSTANDING = 256;
|
|
21
|
+
const ignore = () => { };
|
|
22
|
+
/**
|
|
23
|
+
* Queues a commit's host dispatch as one microtask through `then`: in the same queue as `queueMicrotask()`, which Node
|
|
24
|
+
* wraps in an async resource that costs about twice the memory and three times the time per stored commit.
|
|
25
|
+
*/
|
|
26
|
+
const SETTLED = Promise.resolve();
|
|
27
|
+
/** The default source of a report: a listener, which never fails what ran it. */
|
|
28
|
+
const LISTENER = { kind: "listener_error" };
|
|
29
|
+
/** A telemetry adapter that threw on a commit's event, which must not fail the durable commit. */
|
|
30
|
+
const TELEMETRY = { kind: "host_error", callback: "telemetry" };
|
|
31
|
+
/**
|
|
32
|
+
* Open a Session kernel over one storage backend. `now` is the wall clock for task times; default `Date.now`.
|
|
33
|
+
* `setTimer` arms the timers that evict idle documents; default `setTimeout` (see `HarnessOptions.setTimer`). The first
|
|
11
34
|
* error a Storage method throws fails the Session (`SessionFailed`); see `SessionImpl`.
|
|
12
35
|
*/
|
|
13
36
|
export function createSession(storage, options) {
|
|
14
37
|
const retention = options?.documentRetentionMs;
|
|
15
|
-
|
|
38
|
+
const within = options?.persistWithinMs;
|
|
39
|
+
return new SessionImpl(storage, options?.now, retention === undefined ? undefined : () => retention, options?.setTimer, false, () => within);
|
|
16
40
|
}
|
|
17
41
|
/** Default document retention: ten minutes, as for kept contexts. */
|
|
18
42
|
let defaultRetention = 600_000;
|
|
@@ -27,17 +51,35 @@ export function setDefaultDocumentRetention(ms) {
|
|
|
27
51
|
export function documentRetention(ms) {
|
|
28
52
|
return typeof ms === "number" && ms >= 0 ? ms : defaultRetention;
|
|
29
53
|
}
|
|
30
|
-
/**
|
|
31
|
-
const
|
|
54
|
+
/** Default `withinMs` of a deferred commit, `settings.progress.persistWithinMs`. */
|
|
55
|
+
const DEFAULT_PERSIST_WITHIN_MS = 1_000;
|
|
56
|
+
/**
|
|
57
|
+
* A deferred commit's default window as given, when it is a number of milliseconds, `0` or more (`Infinity` waits for a
|
|
58
|
+
* reading or writing commit or close); otherwise the default.
|
|
59
|
+
*/
|
|
60
|
+
export function persistWithin(ms) {
|
|
61
|
+
return typeof ms === "number" && ms >= 0 ? ms : DEFAULT_PERSIST_WITHIN_MS;
|
|
62
|
+
}
|
|
32
63
|
/**
|
|
33
64
|
* Session kernel: one mutation line, the loaded document tracker cache, and committed publication.
|
|
34
65
|
*
|
|
35
|
-
* The cache is bounded by use: a document with no watch
|
|
66
|
+
* The cache is bounded by use: a document with no watch attached leaves it once it was not read or
|
|
36
67
|
* written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation
|
|
37
68
|
* line, so no transaction ever holds a tracker that leaves.
|
|
38
69
|
*
|
|
39
|
-
* Only committed state is observable.
|
|
40
|
-
*
|
|
70
|
+
* Only committed state is observable. A deferred commit (`CommitPersistence`) is committed in memory first: it is
|
|
71
|
+
* adopted and shown to the Session's own observers, and its changes wait, folded per document and pinned in the cache,
|
|
72
|
+
* for the first commit that reads or writes the document, the document's deadline, a drop of its tracker, or close,
|
|
73
|
+
* which store them.
|
|
74
|
+
*
|
|
75
|
+
* Group commit: the line runs one job at a time, a commit's callback and settlement; a commit with writes then joins
|
|
76
|
+
* the writer's pending batch (logical admission, `Writer`) and the line goes on. The writer stores every commit admitted
|
|
77
|
+
* while no call is in flight in one `commit()` call, off the line, and publishes them one by one, in line order, a
|
|
78
|
+
* macrotask apart: the read caches, adoption, telemetry events, the Session's own observers, and one microtask later
|
|
79
|
+
* `observeCommits()` observers, before every wait the commit resolves and every abort it queues. Host listeners and
|
|
80
|
+
* watch listeners have no order. A deferred commit is published when the commits before it are. A job's every read
|
|
81
|
+
* touches its key (`touch()`); while an unpublished commit writes that key, the job waits for its publication, so no
|
|
82
|
+
* job observes state that is not stored. Outcomes are delivered in line order (`#enqueue()`).
|
|
41
83
|
*
|
|
42
84
|
* Storage failures are final. Every Storage call goes through one guard: the first error a Storage method throws, other
|
|
43
85
|
* than for a cancelled caller, fails the Session, as does a commit it cannot adopt. That call gets the error; every later
|
|
@@ -47,6 +89,13 @@ const MAX_TIMER_DELAY = 2_147_483_647;
|
|
|
47
89
|
*/
|
|
48
90
|
export class SessionImpl {
|
|
49
91
|
#storage;
|
|
92
|
+
/** Newest entry and head marker per conversation; none for a read-only Session, whose Storage another process writes. */
|
|
93
|
+
#tails;
|
|
94
|
+
/**
|
|
95
|
+
* The current documents of each live task this Session created, so the commit that ends a task retires them without
|
|
96
|
+
* a scan. An entry starts with its task's creation and goes when the task is terminal: bounded by live tasks.
|
|
97
|
+
*/
|
|
98
|
+
#taskDocuments = new Map();
|
|
50
99
|
#documents = new Map();
|
|
51
100
|
/** Cached documents with no observer attached, by address, oldest use first, with the time of that use. */
|
|
52
101
|
#idleDocuments = new Map();
|
|
@@ -54,17 +103,45 @@ export class SessionImpl {
|
|
|
54
103
|
#attachedDocuments = new Map();
|
|
55
104
|
#clock;
|
|
56
105
|
#documentRetentionMs;
|
|
106
|
+
#persistWithinMs;
|
|
107
|
+
/** Documents with held changes, by incarnation (record ID); each is pinned in the cache until they are stored. */
|
|
108
|
+
#holds = new Map();
|
|
109
|
+
#holdTimer;
|
|
110
|
+
/** Refuse every commit with writes (`HarnessOptions.readOnly`). */
|
|
111
|
+
#readOnly;
|
|
112
|
+
#timers;
|
|
57
113
|
/** The pending eviction: a timer for the oldest idle document, or a line job when the retention is 0. */
|
|
58
114
|
#documentSweep;
|
|
59
115
|
#sweepJobs = 0;
|
|
60
|
-
/** Set once
|
|
116
|
+
/** Set once the host declined a background timer: then none is kept, and the end of each commit evicts what expired. */
|
|
61
117
|
#documentTimersUnavailable = false;
|
|
118
|
+
/** Host commit observers (`observeCommits()`), each behind `guardObserver()`. */
|
|
62
119
|
#commitListeners = new Set();
|
|
63
120
|
/** The Session's own listeners, such as its scheduler's: they keep memory in step with storage, so a throw fails it. */
|
|
64
121
|
#internalListeners = new Set();
|
|
65
122
|
#closeListeners = new Set();
|
|
66
123
|
#host;
|
|
124
|
+
/** The record cache under the guard, which the writer applies each batch to at its publication. */
|
|
125
|
+
#cached;
|
|
126
|
+
#writer;
|
|
127
|
+
/** The Storage jobs on the line read through: each read waits while its key has an unpublished writer. */
|
|
128
|
+
#gated;
|
|
129
|
+
/** The end of the last line job: the line runs one job at a time. */
|
|
67
130
|
#tail = Promise.resolve();
|
|
131
|
+
/** Line index of the next job; outcomes are delivered in this order. */
|
|
132
|
+
#nextIndex = 0;
|
|
133
|
+
/** The job on the line, while one runs: its index, and whether it waited at a gate. */
|
|
134
|
+
#current;
|
|
135
|
+
/** Settles once every job so far has its outcome delivered. */
|
|
136
|
+
#fence = Promise.resolve();
|
|
137
|
+
#lastDelivered = -1;
|
|
138
|
+
/** Jobs that started on the line and have no outcome delivered yet (`MAX_OUTSTANDING`). */
|
|
139
|
+
#outstanding = 0;
|
|
140
|
+
#room;
|
|
141
|
+
/** The line index whose outcome the failure is: every later job's outcome is `SessionFailed`, except `#ownOutcomes`. */
|
|
142
|
+
#failAt;
|
|
143
|
+
/** Later jobs whose outcome is their own error all the same: the other parts of a storage call that rejected. */
|
|
144
|
+
#ownOutcomes = new Set();
|
|
68
145
|
#closing;
|
|
69
146
|
#failure;
|
|
70
147
|
/** Set when close reaches the backend: calls that fail from then on fail because it closes. */
|
|
@@ -81,10 +158,27 @@ export class SessionImpl {
|
|
|
81
158
|
get failed() {
|
|
82
159
|
return this.#failed.promise;
|
|
83
160
|
}
|
|
84
|
-
constructor(storage, now = Date.now, documentRetentionMs = () => undefined) {
|
|
161
|
+
constructor(storage, now = Date.now, documentRetentionMs = () => undefined, setTimer, readOnly = false, persistWithinMs = () => undefined) {
|
|
85
162
|
// Waits race it; none has to.
|
|
86
163
|
this.#failed.promise.catch(() => { });
|
|
87
|
-
this.#
|
|
164
|
+
this.#readOnly = readOnly;
|
|
165
|
+
// A read-only Session shows a Storage another process writes, so nothing it reads stays current.
|
|
166
|
+
this.#cached = readOnly ? undefined : new CachedStorage(storage);
|
|
167
|
+
this.#storage = new GuardedStorage(this.#cached ?? storage, this);
|
|
168
|
+
this.#gated = new GatedStorage(this.#storage, (key) => this.touch(key));
|
|
169
|
+
this.#writer = new Writer({
|
|
170
|
+
commit: (batches, receive, context) => this.#storage.commit(batches, receive, context),
|
|
171
|
+
stored: (writes) => {
|
|
172
|
+
this.#cached?.published(writes);
|
|
173
|
+
this.#tails?.apply(writes);
|
|
174
|
+
},
|
|
175
|
+
publishing: (publish, context) => this.#publishing(publish, context),
|
|
176
|
+
failure: () => this.#failure,
|
|
177
|
+
fail: (error, at, own) => this.fail(error, undefined, at, own),
|
|
178
|
+
report: (error, context) => this.report(error, LISTENER, context),
|
|
179
|
+
});
|
|
180
|
+
this.#tails = readOnly ? undefined : new ConversationTails();
|
|
181
|
+
this.#timers = hostTimers(setTimer, (error) => this.report(error, { kind: "host_error", callback: "setTimer" }));
|
|
88
182
|
// Cache bookkeeping must never change the outcome of the operation it follows: a clock or a retention setting
|
|
89
183
|
// that throws counts as now, and as the default.
|
|
90
184
|
this.#clock = () => {
|
|
@@ -103,8 +197,17 @@ export class SessionImpl {
|
|
|
103
197
|
return documentRetention(undefined);
|
|
104
198
|
}
|
|
105
199
|
};
|
|
200
|
+
this.#persistWithinMs = () => {
|
|
201
|
+
try {
|
|
202
|
+
return persistWithin(persistWithinMs());
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return persistWithin(undefined);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
106
208
|
this.#host = {
|
|
107
|
-
storage: this.#
|
|
209
|
+
storage: this.#gated,
|
|
210
|
+
touch: (key) => this.touch(key),
|
|
108
211
|
now,
|
|
109
212
|
cached: (id) => {
|
|
110
213
|
const cached = this.#documents.get(id);
|
|
@@ -122,45 +225,167 @@ export class SessionImpl {
|
|
|
122
225
|
this.#dropDocument(id);
|
|
123
226
|
},
|
|
124
227
|
conversationCreated: (tx, record) => this.conversationCreated(tx, record),
|
|
228
|
+
taskDocuments: (taskId) => this.#taskDocuments.get(taskId)?.values(),
|
|
229
|
+
latestEntryId: (conversationId, context) => this.latestEntryId(conversationId, context),
|
|
230
|
+
latestHeadMarker: (conversationId, context) => this.latestHeadMarker(conversationId, undefined, context),
|
|
125
231
|
};
|
|
126
232
|
}
|
|
127
233
|
/** The Storage behind the failure guard; every component reads and writes through it. */
|
|
128
234
|
get storage() {
|
|
129
235
|
return this.#storage;
|
|
130
236
|
}
|
|
131
|
-
|
|
132
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Internal, on the Session line: the newest entry ID visible from `conversationId`. Known from the Session's own
|
|
239
|
+
* commits when it owns its Storage, so it costs no Storage read after the first.
|
|
240
|
+
*/
|
|
241
|
+
latestEntryId(conversationId, context) {
|
|
242
|
+
const gate = this.#touchEntries(conversationId);
|
|
243
|
+
const latest = () => {
|
|
244
|
+
const read = async () => (await this.#storage.scanEntries({ conversationId }, 1, undefined, context)).items[0]?.id;
|
|
245
|
+
return this.#tails === undefined ? read() : this.#tails.latest(conversationId, read);
|
|
246
|
+
};
|
|
247
|
+
return gate === undefined ? latest() : gate.then(latest);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Internal: the newest entry ID of `conversationId` as known from the Session's commits right now, without reading;
|
|
251
|
+
* `undefined` when not known (and always for a read-only Session).
|
|
252
|
+
*/
|
|
253
|
+
knownLatestEntryId(conversationId) {
|
|
254
|
+
return this.#tails?.peek(conversationId);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Internal, on the Session line: the newest visible head marker of `conversationId` at or before `atOrBefore`, any
|
|
258
|
+
* when `undefined`; known from the Session's own commits as `latestEntryId()` is.
|
|
259
|
+
*/
|
|
260
|
+
latestHeadMarker(conversationId, atOrBefore, context) {
|
|
261
|
+
const gate = this.#touchEntries(conversationId);
|
|
262
|
+
const marker = () => {
|
|
263
|
+
const read = () => this.#storage.findLatestHeadMarker(conversationId, atOrBefore, context);
|
|
264
|
+
return this.#tails === undefined ? read() : this.#tails.marker(conversationId, atOrBefore, read);
|
|
265
|
+
};
|
|
266
|
+
return gate === undefined ? marker() : gate.then(marker);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* On the line: wait for what an unpublished commit wrote of a conversation's entries, its appends and its creation,
|
|
270
|
+
* as reading them from Storage fails for a conversation not stored yet.
|
|
271
|
+
*/
|
|
272
|
+
#touchEntries(conversationId) {
|
|
273
|
+
const created = this.touch(conversationKey(conversationId));
|
|
274
|
+
const appended = this.touch(appendKey(conversationId));
|
|
275
|
+
if (created === undefined)
|
|
276
|
+
return appended;
|
|
277
|
+
return appended === undefined ? created : Promise.all([created, appended]).then(ignore);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Internal, on the line: wait, while `key` has an unpublished writer (for `ALL_KEYS`, while anything is unpublished),
|
|
281
|
+
* until it is published, then check that the Session is healthy; `undefined` when there is nothing to wait for. A
|
|
282
|
+
* job's every read touches its key first, so it never observes state an earlier job admitted and the writer has not
|
|
283
|
+
* published.
|
|
284
|
+
*/
|
|
285
|
+
touch(key) {
|
|
286
|
+
const gate = key === ALL_KEYS ? this.#writer.drained() : this.#writer.gate(key);
|
|
287
|
+
if (gate === undefined)
|
|
288
|
+
return undefined;
|
|
289
|
+
if (this.#current !== undefined)
|
|
290
|
+
this.#current.hit = true;
|
|
291
|
+
return gate.then(() => this.#assertHealthy());
|
|
292
|
+
}
|
|
293
|
+
commit(change, context, options) {
|
|
294
|
+
return this.commitWith(change, context, undefined, options);
|
|
133
295
|
}
|
|
134
296
|
/**
|
|
135
297
|
* Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run
|
|
136
298
|
* now; they read the error from `failure`. The Storage guard calls this; the Harness also calls it for a throw in its
|
|
137
|
-
* scheduler's own commits.
|
|
299
|
+
* scheduler's own commits. `at` is the line index of the job whose outcome the failure is, by default the job on the
|
|
300
|
+
* line: later jobs' outcomes become `SessionFailed`.
|
|
138
301
|
*/
|
|
139
|
-
fail(error) {
|
|
302
|
+
fail(error, context, at, own) {
|
|
140
303
|
if (this.#failure !== undefined)
|
|
141
304
|
return;
|
|
142
305
|
this.#failure = { error };
|
|
306
|
+
// The job that caused it keeps its own outcome; without one, every outcome not delivered yet is `SessionFailed`.
|
|
307
|
+
this.#failAt = at ?? this.#current?.index ?? this.#lastDelivered;
|
|
308
|
+
if (own !== undefined)
|
|
309
|
+
for (const index of own)
|
|
310
|
+
this.#ownOutcomes.add(index);
|
|
143
311
|
this.#failed.reject(new SessionFailed(error));
|
|
144
312
|
// Seal first, so a report handler that calls back finds the Session failed. Closing runs the close listeners
|
|
145
313
|
// synchronously; a failing backend close is the caller's to see, and here nobody waits.
|
|
146
314
|
this.close(BACKGROUND_CONTEXT).catch(() => { });
|
|
147
|
-
this.report(error);
|
|
315
|
+
this.report(error, { kind: "storage_failed" }, context);
|
|
148
316
|
}
|
|
149
317
|
/**
|
|
150
318
|
* Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root
|
|
151
319
|
* bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to
|
|
152
320
|
* appended entries.
|
|
153
321
|
*/
|
|
154
|
-
commitWith(change, context, scope) {
|
|
322
|
+
commitWith(change, context, scope, options) {
|
|
323
|
+
let withinMs;
|
|
155
324
|
try {
|
|
156
325
|
this.assertUsable();
|
|
326
|
+
withinMs = this.#deferral(options?.persistence);
|
|
157
327
|
}
|
|
158
328
|
catch (error) {
|
|
159
329
|
return Promise.reject(error);
|
|
160
330
|
}
|
|
161
|
-
return this.#enqueue(() => this.#runCommit(change, context, scope));
|
|
331
|
+
return this.#enqueue((index) => this.#runCommit(change, context, scope, withinMs, index));
|
|
162
332
|
}
|
|
163
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* Internal: `commitWith()` for a commit of the scheduler's own whose outcome goes only to scheduler code. When it
|
|
335
|
+
* writes nothing and waited at no gate, it is equivalent to having run before every unpublished commit, so its
|
|
336
|
+
* outcome is delivered at once, ahead of theirs. One that writes is never exempt: its outcome waits for its own
|
|
337
|
+
* publication and every earlier outcome, so no invocation starts on a reservation that is not durable.
|
|
338
|
+
*/
|
|
339
|
+
commitInternal(change, context) {
|
|
340
|
+
try {
|
|
341
|
+
this.assertUsable();
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
return Promise.reject(error);
|
|
345
|
+
}
|
|
346
|
+
return this.#enqueue((index) => this.#runCommit(change, context, undefined, undefined, index), true);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Internal, on the line: call `end` once the outcome of the job on the line settles, its commit published or never to
|
|
350
|
+
* be; at once off the line.
|
|
351
|
+
*/
|
|
352
|
+
onJobEnd(end) {
|
|
353
|
+
const job = this.#current;
|
|
354
|
+
if (job === undefined) {
|
|
355
|
+
end();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (job.ends === undefined)
|
|
359
|
+
job.ends = [end];
|
|
360
|
+
else
|
|
361
|
+
job.ends.push(end);
|
|
362
|
+
}
|
|
363
|
+
/** Internal: the Storage jobs on the line read through (`touch()`); off the line, use the plain one. */
|
|
364
|
+
get lineStorage() {
|
|
365
|
+
return this.#gated;
|
|
366
|
+
}
|
|
367
|
+
/** How long a commit may stay in memory before it is stored; `undefined` when it is immediate. */
|
|
368
|
+
#deferral(persistence) {
|
|
369
|
+
if (persistence?.kind !== "deferred")
|
|
370
|
+
return undefined;
|
|
371
|
+
const ms = persistence.withinMs;
|
|
372
|
+
if (ms === undefined)
|
|
373
|
+
return this.#persistWithinMs();
|
|
374
|
+
// Public options: checked here, as the types cannot.
|
|
375
|
+
if (typeof ms !== "number" || !(ms >= 0)) {
|
|
376
|
+
throw new TypeError("CommitPersistence.withinMs must be a number of milliseconds, 0 or more");
|
|
377
|
+
}
|
|
378
|
+
return ms;
|
|
379
|
+
}
|
|
380
|
+
/** Internal: whether `tx` is the transaction of one of this Session's commits. */
|
|
381
|
+
ownsTransaction(tx) {
|
|
382
|
+
return Transaction.ownedBy(tx, this.#host);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. It reads
|
|
386
|
+
* through `storage`, whose reads wait for the publication of what the line admitted before; its outcome comes after
|
|
387
|
+
* theirs.
|
|
388
|
+
*/
|
|
164
389
|
readOnLine(job) {
|
|
165
390
|
try {
|
|
166
391
|
this.assertUsable();
|
|
@@ -170,7 +395,7 @@ export class SessionImpl {
|
|
|
170
395
|
}
|
|
171
396
|
return this.#enqueue(async () => {
|
|
172
397
|
this.#assertHealthy();
|
|
173
|
-
return job();
|
|
398
|
+
return { value: await job(this.#gated) };
|
|
174
399
|
});
|
|
175
400
|
}
|
|
176
401
|
/**
|
|
@@ -199,7 +424,7 @@ export class SessionImpl {
|
|
|
199
424
|
? cached
|
|
200
425
|
: await this.#enqueue(async () => {
|
|
201
426
|
this.#assertHealthy();
|
|
202
|
-
return this.#loadDocument(definition, resolved.id, resolved.address, context);
|
|
427
|
+
return { value: await this.#loadDocument(definition, resolved.id, resolved.address, context) };
|
|
203
428
|
});
|
|
204
429
|
if (loaded === undefined)
|
|
205
430
|
return undefined;
|
|
@@ -207,66 +432,40 @@ export class SessionImpl {
|
|
|
207
432
|
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
|
208
433
|
return loaded.tracker.value;
|
|
209
434
|
}
|
|
210
|
-
|
|
435
|
+
watchDoc(token, ...args) {
|
|
436
|
+
// Not async, so an attachment costs one async frame, `#attachDocument`'s; a throw here still rejects.
|
|
211
437
|
try {
|
|
212
438
|
this.assertUsable();
|
|
213
439
|
const definition = token.definition;
|
|
214
440
|
const resolved = resolveAddress(definition, args);
|
|
215
441
|
const context = args[resolved.nextArgument];
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return replicatedState(source, { onError: (error) => this.report(error) });
|
|
224
|
-
}
|
|
225
|
-
catch (error) {
|
|
226
|
-
detach();
|
|
227
|
-
throw error;
|
|
228
|
-
}
|
|
229
|
-
});
|
|
442
|
+
const signal = context.abortSignal;
|
|
443
|
+
return this.#attachDocument(definition, resolved, context, (loaded) => {
|
|
444
|
+
const watch = this.#observeDocument(definition, loaded, createWatch, endWatch);
|
|
445
|
+
if (signal !== undefined)
|
|
446
|
+
watch.observeAbort(signal);
|
|
447
|
+
return watch;
|
|
448
|
+
}, abortWatch);
|
|
230
449
|
}
|
|
231
450
|
catch (error) {
|
|
232
451
|
return Promise.reject(error);
|
|
233
452
|
}
|
|
234
453
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const definition = token.definition;
|
|
238
|
-
const resolved = resolveAddress(definition, args);
|
|
239
|
-
const context = args[resolved.nextArgument];
|
|
240
|
-
const signal = context.abortSignal;
|
|
241
|
-
let cancelled = signal?.aborted ?? false;
|
|
242
|
-
const markCancelled = () => {
|
|
243
|
-
cancelled = true;
|
|
244
|
-
};
|
|
245
|
-
signal?.addEventListener("abort", markCancelled, { once: true });
|
|
454
|
+
observeDoc(token, ...args) {
|
|
455
|
+
// Not async, as `watchDoc()`; a throw here still rejects.
|
|
246
456
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return this.#attachDocument(definition, loaded, (value, release) => new CommittedWatch(value, release, (error) => this.report(error))).observer;
|
|
257
|
-
});
|
|
258
|
-
if (watch === undefined)
|
|
259
|
-
return undefined;
|
|
260
|
-
if (cancelled) {
|
|
261
|
-
watch.cancel();
|
|
262
|
-
throw cancellationError(signal);
|
|
263
|
-
}
|
|
264
|
-
if (signal !== undefined)
|
|
265
|
-
watch.observeCancellation(signal);
|
|
266
|
-
return watch;
|
|
457
|
+
this.assertUsable();
|
|
458
|
+
const definition = token.definition;
|
|
459
|
+
const resolved = resolveAddress(definition, args);
|
|
460
|
+
const observer = args[resolved.nextArgument];
|
|
461
|
+
const context = args[resolved.nextArgument + 1];
|
|
462
|
+
return this.#attachDocument(definition, resolved, context, (loaded) => {
|
|
463
|
+
const guarded = this.#observeDocument(definition, loaded, (_value, detach, report) => guardObserver(observer, detach, report), endGuarded);
|
|
464
|
+
return observation(loaded.tracker.value, guarded);
|
|
465
|
+
}, detachObservation);
|
|
267
466
|
}
|
|
268
|
-
|
|
269
|
-
|
|
467
|
+
catch (error) {
|
|
468
|
+
return Promise.reject(error);
|
|
270
469
|
}
|
|
271
470
|
}
|
|
272
471
|
async snapshotAsOf(token, ...args) {
|
|
@@ -285,7 +484,7 @@ export class SessionImpl {
|
|
|
285
484
|
const context = args[resolved.nextArgument + 1];
|
|
286
485
|
return this.#enqueue(async () => {
|
|
287
486
|
this.#assertHealthy();
|
|
288
|
-
const storedEntry = await this.#
|
|
487
|
+
const storedEntry = await this.#gated.entry(conversationId, at, context);
|
|
289
488
|
if (storedEntry === undefined) {
|
|
290
489
|
throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);
|
|
291
490
|
}
|
|
@@ -293,14 +492,14 @@ export class SessionImpl {
|
|
|
293
492
|
...resolved.address,
|
|
294
493
|
scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId },
|
|
295
494
|
};
|
|
296
|
-
const record = await this.#
|
|
495
|
+
const record = await this.#gated.findDocument(address, storedEntry.commitSeq, context);
|
|
297
496
|
if (record === undefined)
|
|
298
|
-
return undefined;
|
|
299
|
-
const stored = await this.#
|
|
497
|
+
return { value: undefined };
|
|
498
|
+
const stored = await this.#gated.document(record.id, storedEntry.commitSeq, context);
|
|
300
499
|
if (stored === undefined) {
|
|
301
500
|
throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`);
|
|
302
501
|
}
|
|
303
|
-
return materializeDocument(definition, stored);
|
|
502
|
+
return { value: materializeDocument(definition, stored) };
|
|
304
503
|
});
|
|
305
504
|
}
|
|
306
505
|
close(context) {
|
|
@@ -309,12 +508,30 @@ export class SessionImpl {
|
|
|
309
508
|
// Seal admission before anything else runs, then stop observers; admitted work settles before Storage closes.
|
|
310
509
|
this.#closing = Promise.resolve()
|
|
311
510
|
.then(() => this.beforeClose())
|
|
312
|
-
.then(() => this.#
|
|
511
|
+
.then(() => this.#maintain(async () => {
|
|
512
|
+
await this.#drained();
|
|
513
|
+
// Last, after the writes at close (`writeAtClose()`), while the trackers are still here. A failed Session
|
|
514
|
+
// writes nothing; a failing store fails it, and the backend still closes.
|
|
515
|
+
let unstored;
|
|
516
|
+
// Only with something to store: otherwise the backend's close begins in this very job.
|
|
517
|
+
if (this.#failure === undefined && this.#holds.size > 0) {
|
|
518
|
+
await this.#storeHolds(() => [...this.#holds.values()], cleanup).catch((error) => {
|
|
519
|
+
unstored = { error };
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
const observers = [...this.#commitListeners];
|
|
313
523
|
this.#commitListeners.clear();
|
|
524
|
+
// After the last publication: the commits admitted before the seal have settled, held changes are stored.
|
|
525
|
+
// Commit observers are synchronous observers, all of them.
|
|
526
|
+
const end = closeEnds(this.#failure).observer;
|
|
527
|
+
for (const observer of observers)
|
|
528
|
+
observer.end(end);
|
|
314
529
|
this.#internalListeners.clear();
|
|
315
530
|
this.#documents.clear();
|
|
316
531
|
this.#idleDocuments.clear();
|
|
317
532
|
this.#attachedDocuments.clear();
|
|
533
|
+
this.#holds.clear();
|
|
534
|
+
this.#taskDocuments.clear();
|
|
318
535
|
try {
|
|
319
536
|
await this.#storage.close(cleanup);
|
|
320
537
|
}
|
|
@@ -322,14 +539,16 @@ export class SessionImpl {
|
|
|
322
539
|
// A Storage that cannot close is a failed one; an earlier failure stays the cause.
|
|
323
540
|
if (this.#failure === undefined) {
|
|
324
541
|
this.#failure = { error };
|
|
325
|
-
this.report(error);
|
|
542
|
+
this.report(error, { kind: "storage_failed" });
|
|
326
543
|
}
|
|
327
544
|
throw error;
|
|
328
545
|
}
|
|
546
|
+
if (unstored !== undefined)
|
|
547
|
+
throw unstored.error;
|
|
329
548
|
}))
|
|
330
549
|
.finally(() => {
|
|
331
550
|
const failure = this.#failure;
|
|
332
|
-
this.#closed.resolve(failure === undefined ? {
|
|
551
|
+
this.#closed.resolve(failure === undefined ? { kind: "closed" } : { kind: "failed", error: failure.error });
|
|
333
552
|
});
|
|
334
553
|
this.#notifyClose();
|
|
335
554
|
}
|
|
@@ -337,16 +556,20 @@ export class SessionImpl {
|
|
|
337
556
|
}
|
|
338
557
|
/** Run the close listeners once, each on its own: one that fails is reported, and the rest still run. */
|
|
339
558
|
#notifyClose() {
|
|
340
|
-
|
|
341
|
-
clearTimeout(this.#documentSweep.timer);
|
|
559
|
+
this.#documentSweep?.disarm?.();
|
|
342
560
|
this.#documentSweep = undefined;
|
|
561
|
+
this.#holdTimer?.disarm();
|
|
562
|
+
this.#holdTimer = undefined;
|
|
343
563
|
const listeners = [...this.#closeListeners];
|
|
344
564
|
this.#closeListeners.clear();
|
|
345
565
|
for (const listener of listeners)
|
|
346
566
|
contained(listener, (error) => this.report(error));
|
|
347
567
|
}
|
|
348
|
-
/**
|
|
349
|
-
|
|
568
|
+
/**
|
|
569
|
+
* Internal: the Session's failure, and errors of listeners, which never fail what ran them; `info` defaults to a
|
|
570
|
+
* listener. A plain Session drops them.
|
|
571
|
+
*/
|
|
572
|
+
report(_error, _info = LISTENER, _context) { }
|
|
350
573
|
/**
|
|
351
574
|
* Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
|
|
352
575
|
* plain Session stages nothing; a Harness stages its built-in documents.
|
|
@@ -354,21 +577,61 @@ export class SessionImpl {
|
|
|
354
577
|
conversationCreated(_tx, _record) {
|
|
355
578
|
return Promise.resolve();
|
|
356
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* Internal, for `beforeClose()`: on the line, after every commit admitted before the seal has settled, write what
|
|
582
|
+
* `writes()` returns then straight to Storage. Nothing is published, since the Session is closing; a failed Session
|
|
583
|
+
* writes nothing.
|
|
584
|
+
*/
|
|
585
|
+
writeAtClose(writes) {
|
|
586
|
+
return this.#maintain(async (index) => {
|
|
587
|
+
await this.#drained();
|
|
588
|
+
if (this.#failure !== undefined)
|
|
589
|
+
return;
|
|
590
|
+
const batch = writes();
|
|
591
|
+
if (batch.length === 0)
|
|
592
|
+
return;
|
|
593
|
+
const context = BACKGROUND_CONTEXT;
|
|
594
|
+
await this.#writer.admit({
|
|
595
|
+
index,
|
|
596
|
+
writes: batch,
|
|
597
|
+
keys: [],
|
|
598
|
+
context,
|
|
599
|
+
publish: () => undefined,
|
|
600
|
+
discard: ignore,
|
|
601
|
+
}).outcome;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Keys a commit writes besides its records' (`Transaction.writeKeys()`), at its admission, on the line: a Harness
|
|
606
|
+
* adds the ownership memberships its task changes write. A plain Session adds none.
|
|
607
|
+
*/
|
|
608
|
+
membershipKeys(_tx) {
|
|
609
|
+
return NO_KEYS;
|
|
610
|
+
}
|
|
357
611
|
/** Runs after close seals admission and before the line closes Storage; must not reject. */
|
|
358
612
|
beforeClose() {
|
|
359
613
|
return Promise.resolve();
|
|
360
614
|
}
|
|
361
|
-
/**
|
|
362
|
-
|
|
615
|
+
/**
|
|
616
|
+
* Attach a synchronous observer of every stored commit, with exactly what it stored (`CommitPublication`). Its
|
|
617
|
+
* `advance` runs one microtask after the commit's publication, which follows its storage call, in line order, before
|
|
618
|
+
* every wait the commit resolves and every abort it queues (in no order with watch listeners); it must not block or
|
|
619
|
+
* call Session operations. A throw is reported, detaches it, and ends it `listener_error`; `end` gets the Session's
|
|
620
|
+
* close after its last publication.
|
|
621
|
+
*/
|
|
622
|
+
observeCommits(observer) {
|
|
363
623
|
this.assertUsable();
|
|
364
|
-
this.#commitListeners.
|
|
365
|
-
|
|
624
|
+
const guarded = guardObserver(observer, () => this.#commitListeners.delete(guarded), (error, context) => this.report(error, LISTENER, context));
|
|
625
|
+
this.#commitListeners.add(guarded);
|
|
626
|
+
return { detach: () => guarded.detach() };
|
|
366
627
|
}
|
|
367
628
|
/**
|
|
368
|
-
* Internal: `
|
|
369
|
-
* that state behind
|
|
629
|
+
* Internal: follow every commit as memory adopts it (`ObservedCommit`), for the Session's own components, whose state
|
|
630
|
+
* follows memory. A throw leaves that state behind, so it fails the Session instead of being reported. They run at
|
|
631
|
+
* the commit's publication, synchronously, so they call no Storage method, run no task code, and contain every host
|
|
632
|
+
* callback; the waits they resolve and the aborts they queue run after host observers.
|
|
370
633
|
*/
|
|
371
|
-
|
|
634
|
+
followCommits(listener) {
|
|
372
635
|
this.assertUsable();
|
|
373
636
|
this.#internalListeners.add(listener);
|
|
374
637
|
return () => this.#internalListeners.delete(listener);
|
|
@@ -382,16 +645,19 @@ export class SessionImpl {
|
|
|
382
645
|
this.#closeListeners.add(listener);
|
|
383
646
|
return () => this.#closeListeners.delete(listener);
|
|
384
647
|
}
|
|
385
|
-
/** Drop every loaded tracker on the mutation line; later access cold-loads from Storage. */
|
|
648
|
+
/** Drop every loaded tracker on the mutation line, held changes stored first; later access cold-loads from Storage. */
|
|
386
649
|
unloadDocuments() {
|
|
387
|
-
return this.#
|
|
650
|
+
return this.#maintain(async () => {
|
|
651
|
+
if (this.#failure === undefined)
|
|
652
|
+
await this.#storeHolds(() => [...this.#holds.values()], BACKGROUND_CONTEXT);
|
|
388
653
|
this.#documents.clear();
|
|
389
654
|
this.#idleDocuments.clear();
|
|
390
655
|
});
|
|
391
656
|
}
|
|
392
657
|
/**
|
|
393
|
-
* Internal, for tests: how many documents the cache holds, how many are idle, how many incarnations are pinned,
|
|
394
|
-
* many sweep jobs were queued on the line so far
|
|
658
|
+
* Internal, for tests: how many documents the cache holds, how many are idle, how many incarnations are pinned, how
|
|
659
|
+
* many sweep jobs were queued on the line so far, how many documents have held changes, how many live tasks have
|
|
660
|
+
* their documents indexed, and how many conversations have their newest entry kept.
|
|
395
661
|
*/
|
|
396
662
|
get documentCacheSizes() {
|
|
397
663
|
return {
|
|
@@ -399,6 +665,20 @@ export class SessionImpl {
|
|
|
399
665
|
idle: this.#idleDocuments.size,
|
|
400
666
|
attached: this.#attachedDocuments.size,
|
|
401
667
|
sweeps: this.#sweepJobs,
|
|
668
|
+
held: this.#holds.size,
|
|
669
|
+
taskDocuments: this.#taskDocuments.size,
|
|
670
|
+
tails: this.#tails?.size ?? 0,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Internal, for tests that bound memory: jobs whose outcome is not delivered yet, keys with an unpublished writer,
|
|
675
|
+
* and record reads in flight in the cache. All three are 0 once the line is idle.
|
|
676
|
+
*/
|
|
677
|
+
get lineSizes() {
|
|
678
|
+
return {
|
|
679
|
+
outstanding: this.#outstanding,
|
|
680
|
+
unpublished: this.#writer.unpublished,
|
|
681
|
+
reads: this.#cached?.readsInFlight ?? 0,
|
|
402
682
|
};
|
|
403
683
|
}
|
|
404
684
|
/** Note a use of a cached document: an unattached one becomes the newest idle one, and an eviction is scheduled. */
|
|
@@ -425,25 +705,31 @@ export class SessionImpl {
|
|
|
425
705
|
}
|
|
426
706
|
/**
|
|
427
707
|
* Schedule the eviction of the oldest idle document: on the line at once when the retention is 0, otherwise when it
|
|
428
|
-
* expires, by a timer
|
|
429
|
-
* Workers, none is kept: the end of each commit evicts what expired.
|
|
708
|
+
* expires, by a background timer (`HarnessOptions.setTimer`), which never keeps the process alive. Where the host
|
|
709
|
+
* declines it, as the default does in Cloudflare Workers, none is kept: the end of each commit evicts what expired.
|
|
430
710
|
*/
|
|
431
711
|
#scheduleDocumentSweep() {
|
|
432
712
|
if (this.#closing !== undefined)
|
|
433
713
|
return;
|
|
434
|
-
|
|
435
|
-
|
|
714
|
+
// Documents with unpublished changes are not swept: their publication touches them again.
|
|
715
|
+
let oldest;
|
|
716
|
+
for (const [addressId, since] of this.#idleDocuments) {
|
|
717
|
+
if (this.#writer.gate(documentKey(addressId)) !== undefined)
|
|
718
|
+
continue;
|
|
719
|
+
oldest = since;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
if (oldest === undefined)
|
|
436
723
|
return;
|
|
437
|
-
const at = oldest
|
|
724
|
+
const at = oldest + this.#documentRetentionMs();
|
|
438
725
|
if (this.#documentSweep !== undefined && this.#documentSweep.at <= at)
|
|
439
726
|
return;
|
|
440
|
-
|
|
441
|
-
clearTimeout(this.#documentSweep.timer);
|
|
727
|
+
this.#documentSweep?.disarm?.();
|
|
442
728
|
// The pending sweep stays set until its job runs, so touches while the line is busy queue no more.
|
|
443
|
-
const sweep = { at,
|
|
729
|
+
const sweep = { at, disarm: undefined };
|
|
444
730
|
const run = () => {
|
|
445
731
|
this.#sweepJobs++;
|
|
446
|
-
this.#
|
|
732
|
+
this.#maintain(async () => {
|
|
447
733
|
if (this.#documentSweep === sweep)
|
|
448
734
|
this.#documentSweep = undefined;
|
|
449
735
|
if (this.#closing === undefined)
|
|
@@ -460,16 +746,14 @@ export class SessionImpl {
|
|
|
460
746
|
this.#documentSweep = undefined;
|
|
461
747
|
return;
|
|
462
748
|
}
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
if (
|
|
466
|
-
clearTimeout(timer);
|
|
749
|
+
// A timer that fires early sweeps nothing and arms the next one.
|
|
750
|
+
const disarm = this.#timers.background(run, delay);
|
|
751
|
+
if (disarm === undefined) {
|
|
467
752
|
this.#documentTimersUnavailable = true;
|
|
468
753
|
this.#documentSweep = undefined;
|
|
469
754
|
return;
|
|
470
755
|
}
|
|
471
|
-
|
|
472
|
-
sweep.timer = timer;
|
|
756
|
+
sweep.disarm = disarm;
|
|
473
757
|
}
|
|
474
758
|
/** On the line: evict the idle documents whose retention passed, oldest first, then schedule the next eviction. */
|
|
475
759
|
#sweepDocuments() {
|
|
@@ -477,6 +761,9 @@ export class SessionImpl {
|
|
|
477
761
|
for (const [addressId, since] of this.#idleDocuments) {
|
|
478
762
|
if (since > expired)
|
|
479
763
|
break;
|
|
764
|
+
// A document with an unpublished change stays until its publication touches it.
|
|
765
|
+
if (this.#writer.gate(documentKey(addressId)) !== undefined)
|
|
766
|
+
continue;
|
|
480
767
|
this.#dropDocument(addressId);
|
|
481
768
|
}
|
|
482
769
|
this.#scheduleDocumentSweep();
|
|
@@ -500,7 +787,7 @@ export class SessionImpl {
|
|
|
500
787
|
this.#touchDocument(addressId);
|
|
501
788
|
};
|
|
502
789
|
}
|
|
503
|
-
async #runCommit(change, context, scope) {
|
|
790
|
+
async #runCommit(change, context, scope, withinMs, index) {
|
|
504
791
|
this.#assertHealthy();
|
|
505
792
|
context.abortSignal?.throwIfAborted();
|
|
506
793
|
const tx = new Transaction(this.#host, context, scope);
|
|
@@ -527,110 +814,460 @@ export class SessionImpl {
|
|
|
527
814
|
if (writes.length === 0) {
|
|
528
815
|
tx.discard();
|
|
529
816
|
this.#sweepDocuments();
|
|
530
|
-
return result;
|
|
531
|
-
}
|
|
532
|
-
let seq;
|
|
533
|
-
try {
|
|
534
|
-
// Once admitted, caller cancellation does not interrupt Storage settlement.
|
|
535
|
-
seq = await this.#storage.commit(writes, withoutAbortSignal(context));
|
|
817
|
+
return { value: result };
|
|
536
818
|
}
|
|
537
|
-
|
|
538
|
-
//
|
|
819
|
+
if (this.#readOnly) {
|
|
820
|
+
// Refused before Storage: the Session stays healthy, and the caller sees why.
|
|
539
821
|
tx.discard();
|
|
540
|
-
|
|
822
|
+
this.#sweepDocuments();
|
|
823
|
+
throw new ReadOnlySession("a commit with writes");
|
|
824
|
+
}
|
|
825
|
+
const discard = () => tx.discard();
|
|
826
|
+
if (withinMs !== undefined) {
|
|
827
|
+
const held = tx.holdable();
|
|
828
|
+
if (held !== undefined) {
|
|
829
|
+
// Adopted in memory when the commits before it are published, as a part of no storage call.
|
|
830
|
+
const part = this.#writer.admit({
|
|
831
|
+
index,
|
|
832
|
+
writes: undefined,
|
|
833
|
+
keys: tx.writeKeys(),
|
|
834
|
+
context,
|
|
835
|
+
publish: () => this.#hold(tx, held, withinMs, context),
|
|
836
|
+
discard,
|
|
837
|
+
});
|
|
838
|
+
this.#sweepDocuments();
|
|
839
|
+
return { value: result, after: part.outcome };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
let membership = this.membershipKeys(tx);
|
|
843
|
+
if (!Array.isArray(membership)) {
|
|
844
|
+
try {
|
|
845
|
+
membership = await membership;
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
tx.discard();
|
|
849
|
+
this.#sweepDocuments();
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
541
852
|
}
|
|
853
|
+
// Logical admission. Held changes leave their queue only here, once nothing but Storage can fail; a commit never
|
|
854
|
+
// stores a hold adopted after it in line order. Taken before the keys, so the documents it stores held changes of
|
|
855
|
+
// are its writes too: a later job that reads one waits for it instead of storing them again.
|
|
856
|
+
const taken = this.#holds.size === 0 ? NO_HOLDS : tx.takeHeld((id) => this.#holds.get(id));
|
|
857
|
+
const keys = tx.writeKeys();
|
|
858
|
+
for (const key of membership)
|
|
859
|
+
keys.push(key);
|
|
860
|
+
const part = this.#writer.admit({
|
|
861
|
+
index,
|
|
862
|
+
writes,
|
|
863
|
+
keys,
|
|
864
|
+
context,
|
|
865
|
+
publish: (seq) => this.#adopt(tx, writes, taken, seq, context),
|
|
866
|
+
discard,
|
|
867
|
+
});
|
|
868
|
+
this.#sweepDocuments();
|
|
869
|
+
return { value: result, after: part.outcome };
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Publish a stored commit (`Writer`), after the read caches took its writes: adopt it in memory and run its
|
|
873
|
+
* publication. A throw of the adoption leaves memory behind durable state; the writer fails the Session with it.
|
|
874
|
+
*/
|
|
875
|
+
#adopt(tx, writes, taken, seq, context) {
|
|
542
876
|
let documents;
|
|
543
877
|
try {
|
|
544
878
|
documents = tx.adopt(seq);
|
|
545
879
|
}
|
|
546
880
|
catch (error) {
|
|
547
|
-
|
|
548
|
-
this.fail(error);
|
|
549
|
-
throw error;
|
|
881
|
+
return { kind: "adoption", error };
|
|
550
882
|
}
|
|
551
|
-
|
|
883
|
+
this.#indexTaskDocuments(tx, writes, documents, seq);
|
|
884
|
+
const failure = this.#publish(seq, writes, documents, context);
|
|
885
|
+
if (taken.length > 0)
|
|
886
|
+
this.#release(taken);
|
|
887
|
+
for (const document of documents.stored)
|
|
552
888
|
this.#touchDocument(addressId(document.record));
|
|
553
|
-
|
|
554
|
-
this.#sweepDocuments();
|
|
555
|
-
return result;
|
|
889
|
+
return failure === undefined ? undefined : { kind: "listener", error: failure.error };
|
|
556
890
|
}
|
|
557
|
-
|
|
558
|
-
|
|
891
|
+
/**
|
|
892
|
+
* At publication: keep `#taskDocuments` in step with a stored commit: the tasks it creates start with no documents,
|
|
893
|
+
* task documents it creates join their task, ones it retires leave, and tasks it ends leave the index.
|
|
894
|
+
*/
|
|
895
|
+
#indexTaskDocuments(tx, writes, documents, seq) {
|
|
896
|
+
for (const id of tx.createdTaskIds())
|
|
897
|
+
this.#taskDocuments.set(id, new Map());
|
|
898
|
+
if (this.#taskDocuments.size === 0)
|
|
899
|
+
return;
|
|
900
|
+
for (const change of documents.stored) {
|
|
901
|
+
if (change.type !== "document" || change.record.scope.kind !== "task")
|
|
902
|
+
continue;
|
|
903
|
+
const known = this.#taskDocuments.get(change.record.scope.taskId);
|
|
904
|
+
if (known === undefined)
|
|
905
|
+
continue;
|
|
906
|
+
if (change.kind === "retire")
|
|
907
|
+
known.delete(change.record.id);
|
|
908
|
+
else if (change.record.createdAt === seq)
|
|
909
|
+
known.set(change.record.id, change.record);
|
|
910
|
+
}
|
|
911
|
+
for (const write of writes) {
|
|
912
|
+
if (write.type === "task" && write.value.state.status === "terminal")
|
|
913
|
+
this.#taskDocuments.delete(write.value.id);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Hold a deferred commit (`Transaction.holdable()`): adopt it in memory, queue its changes per document, pinned, by
|
|
918
|
+
* the earliest deadline of the document's held commits, and show it to the Session's own observers.
|
|
919
|
+
*/
|
|
920
|
+
#hold(tx, held, withinMs, context) {
|
|
921
|
+
let documents;
|
|
922
|
+
try {
|
|
923
|
+
documents = tx.adoptHeld();
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
// As a stored commit's: memory could not adopt it; the writer fails the Session.
|
|
927
|
+
return { kind: "adoption", error };
|
|
928
|
+
}
|
|
929
|
+
const deadline = this.#clock() + withinMs;
|
|
930
|
+
for (const { loaded, ops, base } of held) {
|
|
931
|
+
const id = loaded.record.id;
|
|
932
|
+
let hold = this.#holds.get(id);
|
|
933
|
+
if (hold === undefined) {
|
|
934
|
+
const unpin = this.#pinDocument(loaded.addressId, id);
|
|
935
|
+
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
|
|
936
|
+
this.#holds.set(id, hold);
|
|
937
|
+
}
|
|
938
|
+
hold.pending.push(ops);
|
|
939
|
+
if (base)
|
|
940
|
+
hold.base = true;
|
|
941
|
+
if (deadline < hold.deadline)
|
|
942
|
+
hold.deadline = deadline;
|
|
943
|
+
}
|
|
944
|
+
this.#armHoldTimer();
|
|
945
|
+
const publication = { kind: "memory", changes: documents };
|
|
946
|
+
let failure;
|
|
947
|
+
for (const listener of [...this.#internalListeners]) {
|
|
948
|
+
// Our own observers are synchronous.
|
|
949
|
+
try {
|
|
950
|
+
listener(publication, context);
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
failure ??= { kind: "listener", error };
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return failure;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Store the held changes `select` returns in one storage transaction, through the writer, from a job on the line. A
|
|
960
|
+
* barrier: they are selected once every commit admitted before is published. Memory already has them, so only
|
|
961
|
+
* `observeCommits()` observers see it, published as the writer publishes it. A failure fails the Session, as every
|
|
962
|
+
* Storage failure does.
|
|
963
|
+
*/
|
|
964
|
+
async #storeHolds(select, context) {
|
|
965
|
+
await this.#drained();
|
|
966
|
+
const holds = select();
|
|
967
|
+
if (holds.length === 0)
|
|
559
968
|
return;
|
|
969
|
+
const writes = [];
|
|
560
970
|
const changes = [];
|
|
971
|
+
const keys = [];
|
|
972
|
+
for (const hold of holds) {
|
|
973
|
+
const { record, tracker, valueVersion: version } = hold.loaded;
|
|
974
|
+
const value = tracker.value;
|
|
975
|
+
const stored = storeHeld(hold, NO_OPERATIONS, value, version);
|
|
976
|
+
writes.push({ type: "document.change", id: record.id, content: stored.content });
|
|
977
|
+
keys.push(documentKey(hold.loaded.addressId), incarnationKey(record.id));
|
|
978
|
+
// Held documents are conversation documents.
|
|
979
|
+
const conversationId = record.scope.kind === "conversation" ? record.scope.conversationId : undefined;
|
|
980
|
+
changes.push({ type: "document", kind: "change", record, conversationId, version, value, ops: stored.ops });
|
|
981
|
+
}
|
|
982
|
+
const publish = (seq) => {
|
|
983
|
+
for (const [index, hold] of holds.entries()) {
|
|
984
|
+
if (writes[index].content.kind === "base")
|
|
985
|
+
hold.loaded.deltasSinceBase = 0;
|
|
986
|
+
else
|
|
987
|
+
hold.loaded.deltasSinceBase++;
|
|
988
|
+
}
|
|
989
|
+
if (this.#commitListeners.size > 0)
|
|
990
|
+
this.#notifyHosts({ seq: seq, changes }, context);
|
|
991
|
+
this.#release(holds);
|
|
992
|
+
return undefined;
|
|
993
|
+
};
|
|
994
|
+
// Run from a job on the line, which a failure it causes is the outcome of.
|
|
995
|
+
const index = this.#current.index;
|
|
996
|
+
await this.#writer.admit({ index, writes, keys, context, publish, discard: ignore }).outcome;
|
|
997
|
+
}
|
|
998
|
+
/** After their store: forget `holds`, unpin their documents, and move the timer to the next deadline. */
|
|
999
|
+
#release(holds) {
|
|
1000
|
+
for (const hold of holds) {
|
|
1001
|
+
this.#holds.delete(hold.loaded.record.id);
|
|
1002
|
+
hold.unpin();
|
|
1003
|
+
}
|
|
1004
|
+
this.#armHoldTimer();
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Keep a foreground timer (Workers refuse background ones) for the earliest deadline of the held documents. When it
|
|
1008
|
+
* fires, every held document is stored, in one storage transaction: a deadline is the latest time, and the others
|
|
1009
|
+
* cost little more in the same transaction. Once fired, it stays until its job ran.
|
|
1010
|
+
*/
|
|
1011
|
+
#armHoldTimer() {
|
|
1012
|
+
if (this.#closing !== undefined)
|
|
1013
|
+
return;
|
|
1014
|
+
let at = Infinity;
|
|
1015
|
+
for (const hold of this.#holds.values())
|
|
1016
|
+
if (hold.deadline < at)
|
|
1017
|
+
at = hold.deadline;
|
|
1018
|
+
const armed = this.#holdTimer;
|
|
1019
|
+
if (armed !== undefined && (armed.fired || armed.at === at))
|
|
1020
|
+
return;
|
|
1021
|
+
armed?.disarm();
|
|
1022
|
+
this.#holdTimer = undefined;
|
|
1023
|
+
if (at === Infinity)
|
|
1024
|
+
return;
|
|
1025
|
+
const timer = { at, disarm: () => { }, fired: false };
|
|
1026
|
+
const run = () => {
|
|
1027
|
+
timer.fired = true;
|
|
1028
|
+
this.#maintain(async () => {
|
|
1029
|
+
if (this.#holdTimer === timer)
|
|
1030
|
+
this.#holdTimer = undefined;
|
|
1031
|
+
// Close stores what is held itself.
|
|
1032
|
+
if (this.#closing !== undefined || this.#failure !== undefined)
|
|
1033
|
+
return;
|
|
1034
|
+
await this.#storeHolds(() => [...this.#holds.values()], BACKGROUND_CONTEXT);
|
|
1035
|
+
}).catch(() => { });
|
|
1036
|
+
};
|
|
1037
|
+
this.#holdTimer = timer;
|
|
1038
|
+
timer.disarm = this.#timers.foreground(run, Math.max(0, at - this.#clock()));
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* At publication: publish a stored commit. Its telemetry events first, then the dispatch to `observeCommits()`
|
|
1042
|
+
* observers, queued before the waits and aborts the observers wake, then the Session's own observers. Never throws:
|
|
1043
|
+
* returns the first error of an own observer, which fails the Session once its storage call settled.
|
|
1044
|
+
*/
|
|
1045
|
+
#publish(seq, writes, documents, context) {
|
|
1046
|
+
const changes = this.#commitListeners.size > 0 || this.#internalListeners.size > 0 ? [] : undefined;
|
|
561
1047
|
for (const write of writes) {
|
|
562
1048
|
switch (write.type) {
|
|
563
|
-
case "conversation":
|
|
564
1049
|
case "entry":
|
|
565
|
-
|
|
1050
|
+
this.#event("pi.durable.entry", { "entry.id": write.value.id, "entry.kind": write.value.kind }, context);
|
|
1051
|
+
changes?.push(write);
|
|
1052
|
+
break;
|
|
566
1053
|
case "submission":
|
|
567
|
-
|
|
1054
|
+
if (write.value.status === "done" || write.value.status === "unanswered") {
|
|
1055
|
+
this.#event("pi.durable.submission", {
|
|
1056
|
+
"submission.id": write.value.id,
|
|
1057
|
+
status: write.value.status,
|
|
1058
|
+
"answer.entry_id": write.value.answer,
|
|
1059
|
+
}, context);
|
|
1060
|
+
}
|
|
1061
|
+
changes?.push(write);
|
|
1062
|
+
break;
|
|
1063
|
+
case "conversation":
|
|
1064
|
+
case "task":
|
|
1065
|
+
changes?.push(write);
|
|
568
1066
|
}
|
|
569
1067
|
}
|
|
570
|
-
|
|
1068
|
+
if (changes === undefined)
|
|
1069
|
+
return undefined;
|
|
1070
|
+
const tables = changes.length;
|
|
1071
|
+
for (const document of documents.observed)
|
|
571
1072
|
changes.push(document);
|
|
572
|
-
const
|
|
573
|
-
|
|
1073
|
+
const observed = { kind: "stored", seq, changes };
|
|
1074
|
+
if (this.#commitListeners.size > 0) {
|
|
1075
|
+
// One publication for both, unless the commit also stored held changes, which only host listeners get.
|
|
1076
|
+
const stored = documents.stored === documents.observed
|
|
1077
|
+
? observed
|
|
1078
|
+
: { seq, changes: [...changes.slice(0, tables), ...documents.stored] };
|
|
1079
|
+
this.#notifyHosts(stored, context);
|
|
1080
|
+
}
|
|
1081
|
+
// The commit is durable: a failing observer neither fails the commit nor skips the others.
|
|
1082
|
+
let failure;
|
|
574
1083
|
for (const listener of [...this.#internalListeners]) {
|
|
575
|
-
|
|
1084
|
+
// Our own observers are synchronous.
|
|
1085
|
+
try {
|
|
1086
|
+
listener(observed, context);
|
|
1087
|
+
}
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
failure ??= { error };
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return failure;
|
|
1093
|
+
}
|
|
1094
|
+
/** At publication: record a commit's telemetry event. A throwing adapter is reported, as publication must not throw. */
|
|
1095
|
+
#event(name, attributes, context) {
|
|
1096
|
+
try {
|
|
1097
|
+
context.telemetry.addEvent(name, attributes);
|
|
576
1098
|
}
|
|
577
|
-
|
|
578
|
-
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
this.report(error, TELEMETRY, context);
|
|
579
1101
|
}
|
|
580
1102
|
}
|
|
581
1103
|
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
1104
|
+
* At publication: call the `observeCommits()` observers with `publication` one microtask from now, and, queued
|
|
1105
|
+
* before the Session's own observers run, before the waits they resolve and the aborts they queue. The observers are
|
|
1106
|
+
* those attached when it runs; their guards report a throw.
|
|
1107
|
+
*/
|
|
1108
|
+
#notifyHosts(publication, context) {
|
|
1109
|
+
void SETTLED.then(() => {
|
|
1110
|
+
for (const observer of [...this.#commitListeners])
|
|
1111
|
+
observer.advance(publication, context);
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* On the line, as one job: load the document at `resolved` and `attach` to it, so the attachment sees every commit
|
|
1116
|
+
* after the value it starts from; `undefined` when absent. Cancelling `context` meanwhile rejects, after `cancel`
|
|
1117
|
+
* undid an attachment made.
|
|
584
1118
|
*/
|
|
585
|
-
#attachDocument(definition,
|
|
1119
|
+
async #attachDocument(definition, resolved, context, attach, cancel) {
|
|
1120
|
+
const signal = context.abortSignal;
|
|
1121
|
+
let cancelled = signal?.aborted ?? false;
|
|
1122
|
+
const markCancelled = () => {
|
|
1123
|
+
cancelled = true;
|
|
1124
|
+
};
|
|
1125
|
+
signal?.addEventListener("abort", markCancelled, { once: true });
|
|
1126
|
+
try {
|
|
1127
|
+
const attached = await this.#enqueue(async () => {
|
|
1128
|
+
this.#assertHealthy();
|
|
1129
|
+
if (cancelled)
|
|
1130
|
+
throw cancellationError(signal);
|
|
1131
|
+
const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
|
|
1132
|
+
if (cancelled)
|
|
1133
|
+
throw cancellationError(signal);
|
|
1134
|
+
return { value: loaded === undefined ? undefined : attach(loaded) };
|
|
1135
|
+
});
|
|
1136
|
+
if (attached === undefined)
|
|
1137
|
+
return undefined;
|
|
1138
|
+
if (cancelled) {
|
|
1139
|
+
cancel(attached);
|
|
1140
|
+
throw cancellationError(signal);
|
|
1141
|
+
}
|
|
1142
|
+
return attached;
|
|
1143
|
+
}
|
|
1144
|
+
finally {
|
|
1145
|
+
signal?.removeEventListener("abort", markCancelled);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Follow the address of the incarnation `loaded`, after checking the definition, with the observer `create` makes
|
|
1150
|
+
* from its value, the idempotent detach, and the report of a listener error; returns that observer. It gets each
|
|
1151
|
+
* committed change there, a retirement as a `retired` change with the last value it was given and no ops (a change
|
|
1152
|
+
* in the retiring commit is not published), and a later incarnation as a root replacement. When the commit makes the
|
|
1153
|
+
* owner task of a task document terminal, no later commit can change the address: after its changes the commit
|
|
1154
|
+
* subscription and the pin go, and `ownerEnded` tells the observer, which stays subscribed to the Session's close
|
|
1155
|
+
* until it ends (its detach drops that). `end` gets the Session's close. The current incarnation is pinned in the
|
|
1156
|
+
* cache. The detach runs before `end`; once it ran, nothing more of the commit underway reaches the observer or pins
|
|
1157
|
+
* anything for it. `create` and `ownerEnded` are module functions where they can be, so a watch keeps no closure of
|
|
1158
|
+
* its caller.
|
|
1159
|
+
*/
|
|
1160
|
+
#observeDocument(definition, loaded, create, ownerEnded) {
|
|
586
1161
|
checkRecordScope(definition, loaded.record);
|
|
587
1162
|
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
|
1163
|
+
// The closures below keep only the address, as the cache's ID and first record, which the cache shares while the
|
|
1164
|
+
// document is cached: `loaded` holds its incarnation's tracker, which a retirement frees.
|
|
1165
|
+
const addressId = loaded.addressId;
|
|
1166
|
+
const address = loaded.record;
|
|
1167
|
+
let incarnation = loaded.record.id;
|
|
1168
|
+
// The value of the incarnation followed; none while the address is absent, so the Session keeps no retired value.
|
|
1169
|
+
let value = loaded.tracker.value;
|
|
1170
|
+
// The definition version the observer's value has: a change of another version replaces it whole.
|
|
1171
|
+
let version = loaded.valueVersion;
|
|
1172
|
+
// The incarnation observed stays cached; once it retires or the observer detaches, it is idle again.
|
|
1173
|
+
let unpin = this.#pinDocument(addressId, incarnation);
|
|
588
1174
|
let unsubscribeCommit = () => { };
|
|
589
1175
|
let unsubscribeClose = () => { };
|
|
590
|
-
|
|
591
|
-
const unpin = this.#pinDocument(loaded.addressId, loaded.record.id);
|
|
1176
|
+
let attached = true;
|
|
592
1177
|
const detach = () => {
|
|
1178
|
+
if (!attached)
|
|
1179
|
+
return;
|
|
1180
|
+
attached = false;
|
|
593
1181
|
unsubscribeCommit();
|
|
594
1182
|
unsubscribeClose();
|
|
595
1183
|
unpin();
|
|
596
1184
|
};
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
observer
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
throw error;
|
|
604
|
-
}
|
|
605
|
-
const observed = { version: loaded.valueVersion };
|
|
606
|
-
unsubscribeCommit = this.observeCommits((publication, context) => {
|
|
1185
|
+
const observer = create(loaded.tracker.value, detach, (error) => this.report(error));
|
|
1186
|
+
unsubscribeCommit = this.followCommits((publication, context) => {
|
|
1187
|
+
// Listeners run from a copy of the set: another observer's call may have detached this one.
|
|
1188
|
+
if (!attached)
|
|
1189
|
+
return;
|
|
1190
|
+
let ownerEnds = false;
|
|
607
1191
|
for (const change of publication.changes) {
|
|
608
|
-
if (change.type
|
|
1192
|
+
if (change.type === "task") {
|
|
1193
|
+
const scope = address.scope;
|
|
1194
|
+
if (scope.kind === "task" &&
|
|
1195
|
+
change.value.id === scope.taskId &&
|
|
1196
|
+
change.value.state.status === "terminal") {
|
|
1197
|
+
ownerEnds = true;
|
|
1198
|
+
}
|
|
609
1199
|
continue;
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
// A migration-only base changes nothing for an observer of the new version.
|
|
614
|
-
if (ops.length === 0)
|
|
1200
|
+
}
|
|
1201
|
+
// The incarnation followed is at the address by definition; only another record needs the address compared.
|
|
1202
|
+
if (change.type !== "document")
|
|
615
1203
|
continue;
|
|
616
|
-
|
|
617
|
-
|
|
1204
|
+
if (change.record.id !== incarnation && !atAddress(change.record, address))
|
|
1205
|
+
continue;
|
|
1206
|
+
if (change.kind === "retire") {
|
|
1207
|
+
if (change.record.id !== incarnation)
|
|
1208
|
+
continue;
|
|
1209
|
+
// Set with every incarnation.
|
|
1210
|
+
const last = value;
|
|
1211
|
+
incarnation = undefined;
|
|
1212
|
+
value = undefined;
|
|
618
1213
|
unpin();
|
|
619
|
-
|
|
1214
|
+
observer.advance({ kind: "value", value: last, ops: NO_OPERATIONS, retired: true }, context);
|
|
1215
|
+
}
|
|
1216
|
+
else if (change.record.id !== incarnation || change.version !== version) {
|
|
1217
|
+
// A new incarnation at the address, or a value of another definition version, whose shape the observer's
|
|
1218
|
+
// value does not have: shown whole.
|
|
1219
|
+
if (change.record.id !== incarnation) {
|
|
1220
|
+
incarnation = change.record.id;
|
|
1221
|
+
unpin();
|
|
1222
|
+
unpin = this.#pinDocument(addressId, incarnation);
|
|
1223
|
+
}
|
|
1224
|
+
version = change.version;
|
|
1225
|
+
value = change.value;
|
|
1226
|
+
observer.advance({ kind: "value", value, ops: [["r", value]], retired: false }, context);
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
// A migration-only base changes nothing for an observer of the new version.
|
|
1230
|
+
if (change.ops.length === 0)
|
|
1231
|
+
continue;
|
|
1232
|
+
value = change.value;
|
|
1233
|
+
observer.advance({ kind: "value", value, ops: change.ops, retired: false }, context);
|
|
1234
|
+
}
|
|
1235
|
+
// The observer may detach in its call, or throw and be detached: the rest of the commit is not its.
|
|
1236
|
+
if (!attached)
|
|
1237
|
+
return;
|
|
620
1238
|
}
|
|
1239
|
+
if (!ownerEnds)
|
|
1240
|
+
return;
|
|
1241
|
+
unsubscribeCommit();
|
|
1242
|
+
unpin();
|
|
1243
|
+
ownerEnded(observer);
|
|
1244
|
+
});
|
|
1245
|
+
unsubscribeClose = this.subscribeClose(() => {
|
|
1246
|
+
detach();
|
|
1247
|
+
observer.end(closeEndOf(observer, closeEnds(this.#failure)));
|
|
621
1248
|
});
|
|
622
|
-
|
|
623
|
-
return { observer, detach };
|
|
1249
|
+
return observer;
|
|
624
1250
|
}
|
|
625
1251
|
async #loadDocument(definition, addressId, address, context) {
|
|
1252
|
+
// What an unpublished commit wrote at the address is in neither the cache nor what Storage shows yet.
|
|
1253
|
+
const gate = this.touch(documentKey(addressId));
|
|
1254
|
+
if (gate !== undefined)
|
|
1255
|
+
await gate;
|
|
626
1256
|
const cached = this.#documents.get(addressId);
|
|
627
1257
|
// A tracker serves only tokens of the version its value was materialized for; others reload from Storage.
|
|
628
1258
|
if (cached?.valueVersion === definition.version) {
|
|
629
1259
|
this.#touchDocument(addressId);
|
|
630
1260
|
return cached;
|
|
631
1261
|
}
|
|
632
|
-
if (cached !== undefined)
|
|
1262
|
+
if (cached !== undefined) {
|
|
1263
|
+
// The reload must find what memory holds: held changes are stored before their tracker goes, after a barrier.
|
|
1264
|
+
const id = cached.record.id;
|
|
1265
|
+
await this.#storeHolds(() => {
|
|
1266
|
+
const hold = this.#holds.get(id);
|
|
1267
|
+
return hold === undefined ? NO_HOLDS : [hold];
|
|
1268
|
+
}, context);
|
|
633
1269
|
this.#dropDocument(addressId);
|
|
1270
|
+
}
|
|
634
1271
|
const record = await this.#storage.findDocument(address, "current", context);
|
|
635
1272
|
if (record === undefined)
|
|
636
1273
|
return undefined;
|
|
@@ -650,10 +1287,139 @@ export class SessionImpl {
|
|
|
650
1287
|
this.#touchDocument(addressId);
|
|
651
1288
|
return loaded;
|
|
652
1289
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1290
|
+
/**
|
|
1291
|
+
* Run `job` on the line, after the jobs before it, and deliver its outcome in line order: once the jobs before it
|
|
1292
|
+
* have theirs, and once its commit, if it admitted one, is published. With `exempt`, a job that admitted no commit and
|
|
1293
|
+
* waited at no gate is delivered as soon as it has its outcome: it is equivalent to having run before every
|
|
1294
|
+
* unpublished commit. A job
|
|
1295
|
+
* whose outcome comes after the one a failure is gets `SessionFailed` instead. At most `MAX_OUTSTANDING` jobs wait
|
|
1296
|
+
* for their delivery; beyond it the line waits.
|
|
1297
|
+
*/
|
|
1298
|
+
#enqueue(work, exempt = false) {
|
|
1299
|
+
const job = { index: this.#nextIndex++, hit: false };
|
|
1300
|
+
const ran = this.#tail.then(async () => {
|
|
1301
|
+
while (this.#outstanding >= MAX_OUTSTANDING) {
|
|
1302
|
+
this.#room ??= Promise.withResolvers();
|
|
1303
|
+
await this.#room.promise;
|
|
1304
|
+
}
|
|
1305
|
+
this.#outstanding++;
|
|
1306
|
+
return this.#onLine(job, work);
|
|
1307
|
+
});
|
|
1308
|
+
this.#tail = ran.then(ignore, ignore);
|
|
1309
|
+
const outcome = ran.then((result) => {
|
|
1310
|
+
if (result.after === undefined) {
|
|
1311
|
+
this.#ended(job);
|
|
1312
|
+
return result.value;
|
|
1313
|
+
}
|
|
1314
|
+
return result.after.then(() => {
|
|
1315
|
+
this.#ended(job);
|
|
1316
|
+
return result.value;
|
|
1317
|
+
}, (error) => {
|
|
1318
|
+
this.#ended(job);
|
|
1319
|
+
throw error;
|
|
1320
|
+
});
|
|
1321
|
+
}, (error) => {
|
|
1322
|
+
this.#ended(job);
|
|
1323
|
+
throw error;
|
|
1324
|
+
});
|
|
1325
|
+
// Delivered later, after the outcomes before it: handled now, so an early rejection is not reported unhandled.
|
|
1326
|
+
outcome.catch(ignore);
|
|
1327
|
+
const fence = this.#fence;
|
|
1328
|
+
this.#fence = fence.then(() => outcome).then(ignore, ignore);
|
|
1329
|
+
const index = job.index;
|
|
1330
|
+
const deliver = (fenced) => (fenced ? fence.then(() => outcome) : outcome).then((value) => {
|
|
1331
|
+
this.#delivered(index, fenced);
|
|
1332
|
+
if (this.#poisoned(index))
|
|
1333
|
+
throw new SessionFailed(this.#failure.error);
|
|
1334
|
+
return value;
|
|
1335
|
+
}, (error) => {
|
|
1336
|
+
this.#delivered(index, fenced);
|
|
1337
|
+
throw this.#poisoned(index) ? new SessionFailed(this.#failure.error) : error;
|
|
1338
|
+
});
|
|
1339
|
+
// Only a job without writes is exempt: one with writes waits for its own publication and the outcomes before it.
|
|
1340
|
+
return exempt
|
|
1341
|
+
? ran.then((result) => deliver(job.hit || result.after !== undefined), () => deliver(job.hit))
|
|
1342
|
+
: deliver(true);
|
|
1343
|
+
}
|
|
1344
|
+
/** Whether job `index` comes after the failure's boundary without an outcome of its own: it gets `SessionFailed`. */
|
|
1345
|
+
#poisoned(index) {
|
|
1346
|
+
return this.#failAt !== undefined && index > this.#failAt && !this.#ownOutcomes.has(index);
|
|
1347
|
+
}
|
|
1348
|
+
/** The job's outcome settled: run what waited for it (`onJobEnd()`). */
|
|
1349
|
+
#ended(job) {
|
|
1350
|
+
const ends = job.ends;
|
|
1351
|
+
if (ends === undefined)
|
|
1352
|
+
return;
|
|
1353
|
+
job.ends = undefined;
|
|
1354
|
+
for (const end of ends)
|
|
1355
|
+
end();
|
|
1356
|
+
}
|
|
1357
|
+
/** Run a job of the Session's own upkeep on the line: no outcome to deliver, so no fence and no `SessionFailed`. */
|
|
1358
|
+
#maintain(work) {
|
|
1359
|
+
const job = { index: this.#nextIndex++, hit: false };
|
|
1360
|
+
const ran = this.#tail.then(() => this.#onLine(job, work));
|
|
1361
|
+
this.#tail = ran.then(ignore, ignore);
|
|
1362
|
+
return ran;
|
|
1363
|
+
}
|
|
1364
|
+
/** Hold the line for job `index` until its line part, through admission, is done. Gates are its touches' own. */
|
|
1365
|
+
async #onLine(job, work) {
|
|
1366
|
+
this.#current = job;
|
|
1367
|
+
try {
|
|
1368
|
+
return await work(job.index);
|
|
1369
|
+
}
|
|
1370
|
+
finally {
|
|
1371
|
+
this.#current = undefined;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
/** A `*` barrier: once every part admitted so far is published, or never will be. */
|
|
1375
|
+
async #drained() {
|
|
1376
|
+
const drained = this.#writer.drained();
|
|
1377
|
+
if (drained !== undefined)
|
|
1378
|
+
await drained;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Run one commit's publication inside the Storage's own scope (`Storage.publishing`), such as a store host's batch;
|
|
1382
|
+
* directly without one. `undefined` when it ran at once, else settles once it ran. A Storage that throws instead of
|
|
1383
|
+
* running it is reported, and the publication runs directly: the commit is stored, so it must be published.
|
|
1384
|
+
*/
|
|
1385
|
+
#publishing(publish, context) {
|
|
1386
|
+
const publishing = this.#storage.publishing;
|
|
1387
|
+
if (publishing === undefined) {
|
|
1388
|
+
publish();
|
|
1389
|
+
return undefined;
|
|
1390
|
+
}
|
|
1391
|
+
const done = Promise.withResolvers();
|
|
1392
|
+
let ran = false;
|
|
1393
|
+
const once = () => {
|
|
1394
|
+
if (ran)
|
|
1395
|
+
return;
|
|
1396
|
+
ran = true;
|
|
1397
|
+
try {
|
|
1398
|
+
publish();
|
|
1399
|
+
}
|
|
1400
|
+
finally {
|
|
1401
|
+
done.resolve();
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
try {
|
|
1405
|
+
publishing(once, context);
|
|
1406
|
+
}
|
|
1407
|
+
catch (error) {
|
|
1408
|
+
this.report(error, LISTENER, context);
|
|
1409
|
+
once();
|
|
1410
|
+
}
|
|
1411
|
+
return done.promise;
|
|
1412
|
+
}
|
|
1413
|
+
/** Job `index` has its outcome: let the line go on past `MAX_OUTSTANDING`. */
|
|
1414
|
+
#delivered(index, fenced) {
|
|
1415
|
+
if (fenced)
|
|
1416
|
+
this.#lastDelivered = index;
|
|
1417
|
+
this.#outstanding--;
|
|
1418
|
+
const room = this.#room;
|
|
1419
|
+
if (room !== undefined && this.#outstanding < MAX_OUTSTANDING) {
|
|
1420
|
+
this.#room = undefined;
|
|
1421
|
+
room.resolve();
|
|
1422
|
+
}
|
|
657
1423
|
}
|
|
658
1424
|
/** Internal: throw `SessionFailed` when failed, else an error when closing. */
|
|
659
1425
|
assertUsable() {
|
|
@@ -682,16 +1448,123 @@ export function contained(callback, onError) {
|
|
|
682
1448
|
}
|
|
683
1449
|
}
|
|
684
1450
|
/**
|
|
685
|
-
*
|
|
686
|
-
* a
|
|
1451
|
+
* `observer`, a host's, behind the rule for observers that run at a commit's publication: one that throws, or whose
|
|
1452
|
+
* `advance` returns a promise that rejects, is reported through `report`, detached, and ended `listener_error`, so the
|
|
1453
|
+
* throw never reaches `adopt`; nothing reaches it after its detach or end, and from then on the guard references
|
|
1454
|
+
* neither it nor its source. `detach` is the source's; the returned `detach()` is the observation's.
|
|
1455
|
+
*/
|
|
1456
|
+
export function guardObserver(observer, detach, report) {
|
|
1457
|
+
let attached = { observer, detach, report };
|
|
1458
|
+
/** Detach from the source once, and hand back what it was attached with. */
|
|
1459
|
+
const release = () => {
|
|
1460
|
+
const released = attached;
|
|
1461
|
+
attached = undefined;
|
|
1462
|
+
released?.detach();
|
|
1463
|
+
return released;
|
|
1464
|
+
};
|
|
1465
|
+
/** Report a throw of `current`'s `advance`, then detach and end it, unless that happened meanwhile. */
|
|
1466
|
+
const fault = (current, error, context) => {
|
|
1467
|
+
current.report(error, context);
|
|
1468
|
+
if (release() !== undefined) {
|
|
1469
|
+
const end = { kind: "listener_error", error: toError(error) };
|
|
1470
|
+
contained(() => current.observer.end(end), current.report);
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
return {
|
|
1474
|
+
advance: (call, context) => {
|
|
1475
|
+
const current = attached;
|
|
1476
|
+
if (current === undefined)
|
|
1477
|
+
return;
|
|
1478
|
+
try {
|
|
1479
|
+
const result = current.observer.advance(call, context);
|
|
1480
|
+
// Any thenable, as `contained()` takes it: a rejection is the observer's throw, after the fact.
|
|
1481
|
+
if (typeof result?.then === "function") {
|
|
1482
|
+
Promise.resolve(result).catch((error) => fault(current, error, context));
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
catch (error) {
|
|
1486
|
+
fault(current, error, context);
|
|
1487
|
+
}
|
|
1488
|
+
},
|
|
1489
|
+
end: (end) => {
|
|
1490
|
+
const ending = release();
|
|
1491
|
+
if (ending !== undefined)
|
|
1492
|
+
contained(() => ending.observer.end(end), ending.report);
|
|
1493
|
+
},
|
|
1494
|
+
detach: () => {
|
|
1495
|
+
release();
|
|
1496
|
+
},
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
/** A listener's throw as the `Error` a `listener_error` end carries, as Chord's watch makes it. */
|
|
1500
|
+
function toError(error) {
|
|
1501
|
+
if (error instanceof Error)
|
|
1502
|
+
return error;
|
|
1503
|
+
try {
|
|
1504
|
+
return new Error(String(error));
|
|
1505
|
+
}
|
|
1506
|
+
catch {
|
|
1507
|
+
return new Error(Object.prototype.toString.call(error));
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
/** The end of an observer whose source closed. */
|
|
1511
|
+
export const ENDED = { kind: "ended" };
|
|
1512
|
+
/**
|
|
1513
|
+
* How a Session's close ends what follows it (decision "Slice 8 S1"), made once per close: an app's watch `ended`; a
|
|
1514
|
+
* synchronous observer (an adapter following the Session, which may open it again) `failed` with "session closed", so
|
|
1515
|
+
* it follows the next Session; `ended` stays for an observed owner that ended (a task document's task). A failed
|
|
1516
|
+
* Session ends both `failed` with its error.
|
|
1517
|
+
*/
|
|
1518
|
+
export function closeEnds(failure) {
|
|
1519
|
+
if (failure !== undefined) {
|
|
1520
|
+
const failed = { kind: "failed", error: failure.error };
|
|
1521
|
+
return { watch: failed, observer: failed };
|
|
1522
|
+
}
|
|
1523
|
+
return { watch: ENDED, observer: { kind: "failed", error: new Error("session closed") } };
|
|
1524
|
+
}
|
|
1525
|
+
/** The end `ends` give `follower`: a watch's, or an observer's. */
|
|
1526
|
+
export function closeEndOf(follower, ends) {
|
|
1527
|
+
return follower instanceof Watch ? ends.watch : ends.observer;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* The `Observation` of a guarded observer attached at `value`. Built outside the attaching closure, so a detached
|
|
1531
|
+
* observation keeps nothing but `value` and the spent guard alive.
|
|
687
1532
|
*/
|
|
688
|
-
function
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1533
|
+
export function observation(value, guarded) {
|
|
1534
|
+
return { value, detach: () => guarded.detach() };
|
|
1535
|
+
}
|
|
1536
|
+
/** A document watch over its source's detach and report. */
|
|
1537
|
+
function createWatch(value, detach, report) {
|
|
1538
|
+
return new Watch(value, detach, report);
|
|
1539
|
+
}
|
|
1540
|
+
/** Undo a watch attached while its acquisition was aborted. */
|
|
1541
|
+
function abortWatch(watch) {
|
|
1542
|
+
watch.abort();
|
|
1543
|
+
}
|
|
1544
|
+
/** Undo an observation attached while its attachment was cancelled. */
|
|
1545
|
+
function detachObservation(attached) {
|
|
1546
|
+
attached.detach();
|
|
1547
|
+
}
|
|
1548
|
+
/** A task document's watch delivers the retirement that ends its task, then ends. */
|
|
1549
|
+
function endWatch(watch) {
|
|
1550
|
+
watch.endAfterDelivery();
|
|
1551
|
+
}
|
|
1552
|
+
/** A task document's observer ends right after the retirement that ends its task. */
|
|
1553
|
+
function endGuarded(guarded) {
|
|
1554
|
+
guarded.end(ENDED);
|
|
1555
|
+
}
|
|
1556
|
+
/** Whether `record` is an incarnation at `address`. */
|
|
1557
|
+
function atAddress(record, address) {
|
|
1558
|
+
if (record.kind !== address.kind || record.key !== address.key)
|
|
1559
|
+
return false;
|
|
1560
|
+
switch (record.scope.kind) {
|
|
1561
|
+
case "session":
|
|
1562
|
+
return address.scope.kind === "session";
|
|
1563
|
+
case "conversation":
|
|
1564
|
+
return address.scope.kind === "conversation" && address.scope.conversationId === record.scope.conversationId;
|
|
1565
|
+
case "task":
|
|
1566
|
+
return address.scope.kind === "task" && address.scope.taskId === record.scope.taskId;
|
|
1567
|
+
}
|
|
695
1568
|
}
|
|
696
1569
|
function cancellationError(signal) {
|
|
697
1570
|
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
@@ -713,9 +1586,26 @@ class GuardedStorage {
|
|
|
713
1586
|
this.#storage = storage;
|
|
714
1587
|
this.#latch = latch;
|
|
715
1588
|
}
|
|
716
|
-
/**
|
|
717
|
-
|
|
718
|
-
return
|
|
1589
|
+
/** The backend's own scope for a publication (`Storage.publishing`), when it has one; not a call to guard. */
|
|
1590
|
+
get publishing() {
|
|
1591
|
+
return scopeOf(this.#storage);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Guarded like every call, but its outcome is the Session writer's to judge: it publishes the batches received first,
|
|
1595
|
+
* then fails the Session for a refusal or a rejection, and for a Session that failed meanwhile.
|
|
1596
|
+
*/
|
|
1597
|
+
async commit(batches, receive, context) {
|
|
1598
|
+
this.#assertHealthy();
|
|
1599
|
+
if (this.#closing)
|
|
1600
|
+
throw new Error("Session is closed");
|
|
1601
|
+
const call = (async () => this.#storage.commit(batches, receive, context))();
|
|
1602
|
+
this.#underway.add(call);
|
|
1603
|
+
try {
|
|
1604
|
+
return await call;
|
|
1605
|
+
}
|
|
1606
|
+
finally {
|
|
1607
|
+
this.#underway.delete(call);
|
|
1608
|
+
}
|
|
719
1609
|
}
|
|
720
1610
|
mintId() {
|
|
721
1611
|
return this.#call(undefined, () => this.#storage.mintId());
|