@earendil-works/pi-durable 1.4.0 → 1.5.1
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 +204 -0
- package/README.md +195 -85
- package/dist/codemode/codemode.d.ts +6 -5
- package/dist/codemode/codemode.d.ts.map +1 -1
- package/dist/codemode/codemode.js +10 -49
- package/dist/codemode/codemode.js.map +1 -1
- package/dist/codemode/models.d.ts +2 -1
- package/dist/codemode/models.d.ts.map +1 -1
- package/dist/codemode/models.js +4 -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 +1 -1
- package/dist/documents.js.map +1 -1
- package/dist/entries.d.ts +1 -1
- package/dist/entries.d.ts.map +1 -1
- package/dist/entries.js.map +1 -1
- package/dist/env/index.d.ts +13 -11
- package/dist/env/index.d.ts.map +1 -1
- package/dist/env/index.js.map +1 -1
- package/dist/env/node-watch.d.ts +4 -4
- 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 +3 -3
- package/dist/env/node.d.ts.map +1 -1
- package/dist/env/node.js +1 -1
- 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.map +1 -1
- package/dist/harness/agent.js +37 -3
- package/dist/harness/agent.js.map +1 -1
- package/dist/harness/compaction.d.ts +8 -3
- package/dist/harness/compaction.d.ts.map +1 -1
- package/dist/harness/compaction.js +40 -11
- package/dist/harness/compaction.js.map +1 -1
- package/dist/harness/context.d.ts +24 -8
- 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 +8 -4
- 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 +39 -6
- package/dist/harness/generation.d.ts.map +1 -1
- package/dist/harness/generation.js +451 -100
- package/dist/harness/generation.js.map +1 -1
- package/dist/harness/harness.d.ts +3 -1
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +87 -62
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/inbox.d.ts +7 -2
- package/dist/harness/inbox.d.ts.map +1 -1
- package/dist/harness/inbox.js +5 -2
- package/dist/harness/inbox.js.map +1 -1
- package/dist/harness/json.d.ts +21 -5
- 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 +124 -17
- 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 +163 -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.map +1 -1
- package/dist/harness/provider.d.ts +1 -1
- package/dist/harness/provider.d.ts.map +1 -1
- package/dist/harness/provider.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 +48 -20
- package/dist/harness/scheduler.d.ts.map +1 -1
- package/dist/harness/scheduler.js +1006 -380
- package/dist/harness/scheduler.js.map +1 -1
- package/dist/harness/submissions.d.ts +18 -11
- package/dist/harness/submissions.d.ts.map +1 -1
- package/dist/harness/submissions.js +63 -26
- package/dist/harness/submissions.js.map +1 -1
- package/dist/harness/task-graph.d.ts +14 -9
- package/dist/harness/task-graph.d.ts.map +1 -1
- package/dist/harness/task-graph.js +38 -28
- package/dist/harness/task-graph.js.map +1 -1
- package/dist/harness/telemetry.d.ts +11 -11
- package/dist/harness/telemetry.d.ts.map +1 -1
- package/dist/harness/telemetry.js +7 -7
- package/dist/harness/telemetry.js.map +1 -1
- package/dist/harness/tool.d.ts +3 -1
- package/dist/harness/tool.d.ts.map +1 -1
- package/dist/harness/tool.js +172 -56
- 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 +237 -61
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/usage.d.ts +2 -1
- package/dist/harness/usage.d.ts.map +1 -1
- package/dist/harness/usage.js +1 -1
- package/dist/harness/usage.js.map +1 -1
- package/dist/harness/util.d.ts +4 -2
- package/dist/harness/util.d.ts.map +1 -1
- package/dist/harness/util.js +9 -0
- package/dist/harness/util.js.map +1 -1
- package/dist/harness/view.d.ts +44 -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 +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- 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 +72 -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/forks.d.ts +1 -1
- package/dist/session/forks.d.ts.map +1 -1
- package/dist/session/forks.js.map +1 -1
- package/dist/session/keys.d.ts +69 -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 +169 -26
- package/dist/session/session.d.ts.map +1 -1
- package/dist/session/session.js +1029 -171
- package/dist/session/session.js.map +1 -1
- package/dist/session/transaction.d.ts +84 -5
- package/dist/session/transaction.d.ts.map +1 -1
- package/dist/session/transaction.js +227 -40
- 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 +12 -3
- package/dist/storage/indexeddb.d.ts.map +1 -1
- package/dist/storage/indexeddb.js +91 -41
- package/dist/storage/indexeddb.js.map +1 -1
- package/dist/storage/jsonl/index.d.ts +1 -1
- package/dist/storage/jsonl/index.d.ts.map +1 -1
- package/dist/storage/jsonl/index.js +1 -1
- package/dist/storage/jsonl/index.js.map +1 -1
- package/dist/storage/jsonl/node.d.ts +3 -3
- package/dist/storage/jsonl/node.d.ts.map +1 -1
- package/dist/storage/jsonl/node.js +2 -2
- package/dist/storage/jsonl/node.js.map +1 -1
- package/dist/storage/jsonl/storage.d.ts +46 -5
- package/dist/storage/jsonl/storage.d.ts.map +1 -1
- package/dist/storage/jsonl/storage.js +158 -43
- package/dist/storage/jsonl/storage.js.map +1 -1
- package/dist/storage/memory.d.ts +9 -3
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +28 -2
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite/commit.d.ts +52 -0
- package/dist/storage/sqlite/commit.d.ts.map +1 -0
- package/dist/storage/sqlite/commit.js +371 -0
- package/dist/storage/sqlite/commit.js.map +1 -0
- package/dist/storage/sqlite/rows.d.ts +33 -0
- package/dist/storage/sqlite/rows.d.ts.map +1 -0
- package/dist/storage/sqlite/rows.js +53 -0
- package/dist/storage/sqlite/rows.js.map +1 -0
- package/dist/storage/sqlite/statements.d.ts +13 -0
- package/dist/storage/sqlite/statements.d.ts.map +1 -0
- package/dist/storage/sqlite/statements.js +88 -0
- package/dist/storage/sqlite/statements.js.map +1 -0
- package/dist/storage/sqlite/storage.d.ts +23 -10
- package/dist/storage/sqlite/storage.d.ts.map +1 -1
- package/dist/storage/sqlite/storage.js +120 -261
- 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 +60 -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 +354 -69
- package/dist/testing/storage-conformance.js.map +1 -1
- package/dist/tool-search/tool-search.d.ts +8 -14
- package/dist/tool-search/tool-search.d.ts.map +1 -1
- package/dist/tool-search/tool-search.js +25 -13
- package/dist/tool-search/tool-search.js.map +1 -1
- package/dist/tools/bash.d.ts +13 -13
- 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/file-mutation-queue.d.ts +1 -1
- package/dist/tools/file-mutation-queue.d.ts.map +1 -1
- package/dist/tools/file-mutation-queue.js.map +1 -1
- package/dist/tools/index.d.ts +32 -32
- package/dist/tools/path-utils.d.ts +1 -1
- package/dist/tools/path-utils.d.ts.map +1 -1
- package/dist/tools/path-utils.js.map +1 -1
- 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 +228 -78
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +19 -12
- 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,14 +1,33 @@
|
|
|
1
|
-
import { replicatedState } from "@earendil-works/chord";
|
|
2
1
|
import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
|
|
3
|
-
import { track } from "@earendil-works/chord/delta";
|
|
2
|
+
import { PendingOps, track } from "@earendil-works/chord/delta";
|
|
3
|
+
import { Watch } from "@earendil-works/chord/watch";
|
|
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
7
|
import { hostTimers } from "../timers.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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 = [];
|
|
16
|
+
/**
|
|
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();
|
|
10
27
|
/** The default source of a report: a listener, which never fails what ran it. */
|
|
11
|
-
const LISTENER = {
|
|
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" };
|
|
12
31
|
/**
|
|
13
32
|
* Open a Session kernel over one storage backend. `now` is the wall clock for task times; default `Date.now`.
|
|
14
33
|
* `setTimer` arms the timers that evict idle documents; default `setTimeout` (see `HarnessOptions.setTimer`). The first
|
|
@@ -16,7 +35,8 @@ const LISTENER = { type: "listener_error" };
|
|
|
16
35
|
*/
|
|
17
36
|
export function createSession(storage, options) {
|
|
18
37
|
const retention = options?.documentRetentionMs;
|
|
19
|
-
|
|
38
|
+
const within = options?.persistWithinMs;
|
|
39
|
+
return new SessionImpl(storage, options?.now, retention === undefined ? undefined : () => retention, options?.setTimer, false, () => within);
|
|
20
40
|
}
|
|
21
41
|
/** Default document retention: ten minutes, as for kept contexts. */
|
|
22
42
|
let defaultRetention = 600_000;
|
|
@@ -31,15 +51,35 @@ export function setDefaultDocumentRetention(ms) {
|
|
|
31
51
|
export function documentRetention(ms) {
|
|
32
52
|
return typeof ms === "number" && ms >= 0 ? ms : defaultRetention;
|
|
33
53
|
}
|
|
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
|
+
}
|
|
34
63
|
/**
|
|
35
64
|
* Session kernel: one mutation line, the loaded document tracker cache, and committed publication.
|
|
36
65
|
*
|
|
37
|
-
* 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
|
|
38
67
|
* written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation
|
|
39
68
|
* line, so no transaction ever holds a tracker that leaves.
|
|
40
69
|
*
|
|
41
|
-
* Only committed state is observable.
|
|
42
|
-
*
|
|
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()`).
|
|
43
83
|
*
|
|
44
84
|
* Storage failures are final. Every Storage call goes through one guard: the first error a Storage method throws, other
|
|
45
85
|
* than for a cancelled caller, fails the Session, as does a commit it cannot adopt. That call gets the error; every later
|
|
@@ -49,6 +89,13 @@ export function documentRetention(ms) {
|
|
|
49
89
|
*/
|
|
50
90
|
export class SessionImpl {
|
|
51
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();
|
|
52
99
|
#documents = new Map();
|
|
53
100
|
/** Cached documents with no observer attached, by address, oldest use first, with the time of that use. */
|
|
54
101
|
#idleDocuments = new Map();
|
|
@@ -56,18 +103,45 @@ export class SessionImpl {
|
|
|
56
103
|
#attachedDocuments = new Map();
|
|
57
104
|
#clock;
|
|
58
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;
|
|
59
112
|
#timers;
|
|
60
113
|
/** The pending eviction: a timer for the oldest idle document, or a line job when the retention is 0. */
|
|
61
114
|
#documentSweep;
|
|
62
115
|
#sweepJobs = 0;
|
|
63
116
|
/** Set once the host declined a background timer: then none is kept, and the end of each commit evicts what expired. */
|
|
64
117
|
#documentTimersUnavailable = false;
|
|
118
|
+
/** Host commit observers (`observeCommits()`), each behind `guardObserver()`. */
|
|
65
119
|
#commitListeners = new Set();
|
|
66
120
|
/** The Session's own listeners, such as its scheduler's: they keep memory in step with storage, so a throw fails it. */
|
|
67
121
|
#internalListeners = new Set();
|
|
68
122
|
#closeListeners = new Set();
|
|
69
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. */
|
|
70
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();
|
|
71
145
|
#closing;
|
|
72
146
|
#failure;
|
|
73
147
|
/** Set when close reaches the backend: calls that fail from then on fail because it closes. */
|
|
@@ -84,11 +158,27 @@ export class SessionImpl {
|
|
|
84
158
|
get failed() {
|
|
85
159
|
return this.#failed.promise;
|
|
86
160
|
}
|
|
87
|
-
constructor(storage, now = Date.now, documentRetentionMs = () => undefined, setTimer) {
|
|
161
|
+
constructor(storage, now = Date.now, documentRetentionMs = () => undefined, setTimer, readOnly = false, persistWithinMs = () => undefined) {
|
|
88
162
|
// Waits race it; none has to.
|
|
89
163
|
this.#failed.promise.catch(() => { });
|
|
90
|
-
this.#
|
|
91
|
-
|
|
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" }));
|
|
92
182
|
// Cache bookkeeping must never change the outcome of the operation it follows: a clock or a retention setting
|
|
93
183
|
// that throws counts as now, and as the default.
|
|
94
184
|
this.#clock = () => {
|
|
@@ -107,8 +197,17 @@ export class SessionImpl {
|
|
|
107
197
|
return documentRetention(undefined);
|
|
108
198
|
}
|
|
109
199
|
};
|
|
200
|
+
this.#persistWithinMs = () => {
|
|
201
|
+
try {
|
|
202
|
+
return persistWithin(persistWithinMs());
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return persistWithin(undefined);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
110
208
|
this.#host = {
|
|
111
|
-
storage: this.#
|
|
209
|
+
storage: this.#gated,
|
|
210
|
+
touch: (key) => this.touch(key),
|
|
112
211
|
now,
|
|
113
212
|
cached: (id) => {
|
|
114
213
|
const cached = this.#documents.get(id);
|
|
@@ -126,45 +225,167 @@ export class SessionImpl {
|
|
|
126
225
|
this.#dropDocument(id);
|
|
127
226
|
},
|
|
128
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),
|
|
129
231
|
};
|
|
130
232
|
}
|
|
131
233
|
/** The Storage behind the failure guard; every component reads and writes through it. */
|
|
132
234
|
get storage() {
|
|
133
235
|
return this.#storage;
|
|
134
236
|
}
|
|
135
|
-
|
|
136
|
-
|
|
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);
|
|
137
295
|
}
|
|
138
296
|
/**
|
|
139
297
|
* Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run
|
|
140
298
|
* now; they read the error from `failure`. The Storage guard calls this; the Harness also calls it for a throw in its
|
|
141
|
-
* 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`.
|
|
142
301
|
*/
|
|
143
|
-
fail(error, context) {
|
|
302
|
+
fail(error, context, at, own) {
|
|
144
303
|
if (this.#failure !== undefined)
|
|
145
304
|
return;
|
|
146
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);
|
|
147
311
|
this.#failed.reject(new SessionFailed(error));
|
|
148
312
|
// Seal first, so a report handler that calls back finds the Session failed. Closing runs the close listeners
|
|
149
313
|
// synchronously; a failing backend close is the caller's to see, and here nobody waits.
|
|
150
314
|
this.close(BACKGROUND_CONTEXT).catch(() => { });
|
|
151
|
-
this.report(error, {
|
|
315
|
+
this.report(error, { kind: "storage_failed" }, context);
|
|
152
316
|
}
|
|
153
317
|
/**
|
|
154
318
|
* Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root
|
|
155
319
|
* bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to
|
|
156
320
|
* appended entries.
|
|
157
321
|
*/
|
|
158
|
-
commitWith(change, context, scope) {
|
|
322
|
+
commitWith(change, context, scope, options) {
|
|
323
|
+
let withinMs;
|
|
159
324
|
try {
|
|
160
325
|
this.assertUsable();
|
|
326
|
+
withinMs = this.#deferral(options?.persistence);
|
|
161
327
|
}
|
|
162
328
|
catch (error) {
|
|
163
329
|
return Promise.reject(error);
|
|
164
330
|
}
|
|
165
|
-
return this.#enqueue(() => this.#runCommit(change, context, scope));
|
|
331
|
+
return this.#enqueue((index) => this.#runCommit(change, context, scope, withinMs, index));
|
|
166
332
|
}
|
|
167
|
-
/**
|
|
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
|
+
*/
|
|
168
389
|
readOnLine(job) {
|
|
169
390
|
try {
|
|
170
391
|
this.assertUsable();
|
|
@@ -174,7 +395,7 @@ export class SessionImpl {
|
|
|
174
395
|
}
|
|
175
396
|
return this.#enqueue(async () => {
|
|
176
397
|
this.#assertHealthy();
|
|
177
|
-
return job();
|
|
398
|
+
return { value: await job(this.#gated) };
|
|
178
399
|
});
|
|
179
400
|
}
|
|
180
401
|
/**
|
|
@@ -203,7 +424,7 @@ export class SessionImpl {
|
|
|
203
424
|
? cached
|
|
204
425
|
: await this.#enqueue(async () => {
|
|
205
426
|
this.#assertHealthy();
|
|
206
|
-
return this.#loadDocument(definition, resolved.id, resolved.address, context);
|
|
427
|
+
return { value: await this.#loadDocument(definition, resolved.id, resolved.address, context) };
|
|
207
428
|
});
|
|
208
429
|
if (loaded === undefined)
|
|
209
430
|
return undefined;
|
|
@@ -211,66 +432,40 @@ export class SessionImpl {
|
|
|
211
432
|
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
|
212
433
|
return loaded.tracker.value;
|
|
213
434
|
}
|
|
214
|
-
|
|
435
|
+
watchDoc(token, ...args) {
|
|
436
|
+
// Not async, so an attachment costs one async frame, `#attachDocument`'s; a throw here still rejects.
|
|
215
437
|
try {
|
|
216
438
|
this.assertUsable();
|
|
217
439
|
const definition = token.definition;
|
|
218
440
|
const resolved = resolveAddress(definition, args);
|
|
219
441
|
const context = args[resolved.nextArgument];
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return replicatedState(source, { onError: (error) => this.report(error) });
|
|
228
|
-
}
|
|
229
|
-
catch (error) {
|
|
230
|
-
detach();
|
|
231
|
-
throw error;
|
|
232
|
-
}
|
|
233
|
-
});
|
|
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);
|
|
234
449
|
}
|
|
235
450
|
catch (error) {
|
|
236
451
|
return Promise.reject(error);
|
|
237
452
|
}
|
|
238
453
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const definition = token.definition;
|
|
242
|
-
const resolved = resolveAddress(definition, args);
|
|
243
|
-
const context = args[resolved.nextArgument];
|
|
244
|
-
const signal = context.abortSignal;
|
|
245
|
-
let cancelled = signal?.aborted ?? false;
|
|
246
|
-
const markCancelled = () => {
|
|
247
|
-
cancelled = true;
|
|
248
|
-
};
|
|
249
|
-
signal?.addEventListener("abort", markCancelled, { once: true });
|
|
454
|
+
observeDoc(token, ...args) {
|
|
455
|
+
// Not async, as `watchDoc()`; a throw here still rejects.
|
|
250
456
|
try {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return this.#attachDocument(definition, loaded, (value, release) => new CommittedWatch(value, release, (error) => this.report(error))).observer;
|
|
261
|
-
});
|
|
262
|
-
if (watch === undefined)
|
|
263
|
-
return undefined;
|
|
264
|
-
if (cancelled) {
|
|
265
|
-
watch.cancel();
|
|
266
|
-
throw cancellationError(signal);
|
|
267
|
-
}
|
|
268
|
-
if (signal !== undefined)
|
|
269
|
-
watch.observeCancellation(signal);
|
|
270
|
-
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);
|
|
271
466
|
}
|
|
272
|
-
|
|
273
|
-
|
|
467
|
+
catch (error) {
|
|
468
|
+
return Promise.reject(error);
|
|
274
469
|
}
|
|
275
470
|
}
|
|
276
471
|
async snapshotAsOf(token, ...args) {
|
|
@@ -289,7 +484,7 @@ export class SessionImpl {
|
|
|
289
484
|
const context = args[resolved.nextArgument + 1];
|
|
290
485
|
return this.#enqueue(async () => {
|
|
291
486
|
this.#assertHealthy();
|
|
292
|
-
const storedEntry = await this.#
|
|
487
|
+
const storedEntry = await this.#gated.entry(conversationId, at, context);
|
|
293
488
|
if (storedEntry === undefined) {
|
|
294
489
|
throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);
|
|
295
490
|
}
|
|
@@ -297,14 +492,14 @@ export class SessionImpl {
|
|
|
297
492
|
...resolved.address,
|
|
298
493
|
scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId },
|
|
299
494
|
};
|
|
300
|
-
const record = await this.#
|
|
495
|
+
const record = await this.#gated.findDocument(address, storedEntry.commitSeq, context);
|
|
301
496
|
if (record === undefined)
|
|
302
|
-
return undefined;
|
|
303
|
-
const stored = await this.#
|
|
497
|
+
return { value: undefined };
|
|
498
|
+
const stored = await this.#gated.document(record.id, storedEntry.commitSeq, context);
|
|
304
499
|
if (stored === undefined) {
|
|
305
500
|
throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`);
|
|
306
501
|
}
|
|
307
|
-
return materializeDocument(definition, stored);
|
|
502
|
+
return { value: materializeDocument(definition, stored) };
|
|
308
503
|
});
|
|
309
504
|
}
|
|
310
505
|
close(context) {
|
|
@@ -313,12 +508,30 @@ export class SessionImpl {
|
|
|
313
508
|
// Seal admission before anything else runs, then stop observers; admitted work settles before Storage closes.
|
|
314
509
|
this.#closing = Promise.resolve()
|
|
315
510
|
.then(() => this.beforeClose())
|
|
316
|
-
.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];
|
|
317
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);
|
|
318
529
|
this.#internalListeners.clear();
|
|
319
530
|
this.#documents.clear();
|
|
320
531
|
this.#idleDocuments.clear();
|
|
321
532
|
this.#attachedDocuments.clear();
|
|
533
|
+
this.#holds.clear();
|
|
534
|
+
this.#taskDocuments.clear();
|
|
322
535
|
try {
|
|
323
536
|
await this.#storage.close(cleanup);
|
|
324
537
|
}
|
|
@@ -326,14 +539,16 @@ export class SessionImpl {
|
|
|
326
539
|
// A Storage that cannot close is a failed one; an earlier failure stays the cause.
|
|
327
540
|
if (this.#failure === undefined) {
|
|
328
541
|
this.#failure = { error };
|
|
329
|
-
this.report(error, {
|
|
542
|
+
this.report(error, { kind: "storage_failed" });
|
|
330
543
|
}
|
|
331
544
|
throw error;
|
|
332
545
|
}
|
|
546
|
+
if (unstored !== undefined)
|
|
547
|
+
throw unstored.error;
|
|
333
548
|
}))
|
|
334
549
|
.finally(() => {
|
|
335
550
|
const failure = this.#failure;
|
|
336
|
-
this.#closed.resolve(failure === undefined ? {
|
|
551
|
+
this.#closed.resolve(failure === undefined ? { kind: "closed" } : { kind: "failed", error: failure.error });
|
|
337
552
|
});
|
|
338
553
|
this.#notifyClose();
|
|
339
554
|
}
|
|
@@ -343,6 +558,8 @@ export class SessionImpl {
|
|
|
343
558
|
#notifyClose() {
|
|
344
559
|
this.#documentSweep?.disarm?.();
|
|
345
560
|
this.#documentSweep = undefined;
|
|
561
|
+
this.#holdTimer?.disarm();
|
|
562
|
+
this.#holdTimer = undefined;
|
|
346
563
|
const listeners = [...this.#closeListeners];
|
|
347
564
|
this.#closeListeners.clear();
|
|
348
565
|
for (const listener of listeners)
|
|
@@ -366,29 +583,55 @@ export class SessionImpl {
|
|
|
366
583
|
* writes nothing.
|
|
367
584
|
*/
|
|
368
585
|
writeAtClose(writes) {
|
|
369
|
-
return this.#
|
|
586
|
+
return this.#maintain(async (index) => {
|
|
587
|
+
await this.#drained();
|
|
370
588
|
if (this.#failure !== undefined)
|
|
371
589
|
return;
|
|
372
590
|
const batch = writes();
|
|
373
|
-
if (batch.length
|
|
374
|
-
|
|
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;
|
|
375
602
|
});
|
|
376
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
|
+
}
|
|
377
611
|
/** Runs after close seals admission and before the line closes Storage; must not reject. */
|
|
378
612
|
beforeClose() {
|
|
379
613
|
return Promise.resolve();
|
|
380
614
|
}
|
|
381
|
-
/**
|
|
382
|
-
|
|
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) {
|
|
383
623
|
this.assertUsable();
|
|
384
|
-
this.#commitListeners.
|
|
385
|
-
|
|
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() };
|
|
386
627
|
}
|
|
387
628
|
/**
|
|
388
|
-
* Internal: `
|
|
389
|
-
* 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.
|
|
390
633
|
*/
|
|
391
|
-
|
|
634
|
+
followCommits(listener) {
|
|
392
635
|
this.assertUsable();
|
|
393
636
|
this.#internalListeners.add(listener);
|
|
394
637
|
return () => this.#internalListeners.delete(listener);
|
|
@@ -402,16 +645,19 @@ export class SessionImpl {
|
|
|
402
645
|
this.#closeListeners.add(listener);
|
|
403
646
|
return () => this.#closeListeners.delete(listener);
|
|
404
647
|
}
|
|
405
|
-
/** 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. */
|
|
406
649
|
unloadDocuments() {
|
|
407
|
-
return this.#
|
|
650
|
+
return this.#maintain(async () => {
|
|
651
|
+
if (this.#failure === undefined)
|
|
652
|
+
await this.#storeHolds(() => [...this.#holds.values()], BACKGROUND_CONTEXT);
|
|
408
653
|
this.#documents.clear();
|
|
409
654
|
this.#idleDocuments.clear();
|
|
410
655
|
});
|
|
411
656
|
}
|
|
412
657
|
/**
|
|
413
|
-
* Internal, for tests: how many documents the cache holds, how many are idle, how many incarnations are pinned,
|
|
414
|
-
* 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.
|
|
415
661
|
*/
|
|
416
662
|
get documentCacheSizes() {
|
|
417
663
|
return {
|
|
@@ -419,6 +665,20 @@ export class SessionImpl {
|
|
|
419
665
|
idle: this.#idleDocuments.size,
|
|
420
666
|
attached: this.#attachedDocuments.size,
|
|
421
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,
|
|
422
682
|
};
|
|
423
683
|
}
|
|
424
684
|
/** Note a use of a cached document: an unattached one becomes the newest idle one, and an eviction is scheduled. */
|
|
@@ -451,10 +711,17 @@ export class SessionImpl {
|
|
|
451
711
|
#scheduleDocumentSweep() {
|
|
452
712
|
if (this.#closing !== undefined)
|
|
453
713
|
return;
|
|
454
|
-
|
|
455
|
-
|
|
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)
|
|
456
723
|
return;
|
|
457
|
-
const at = oldest
|
|
724
|
+
const at = oldest + this.#documentRetentionMs();
|
|
458
725
|
if (this.#documentSweep !== undefined && this.#documentSweep.at <= at)
|
|
459
726
|
return;
|
|
460
727
|
this.#documentSweep?.disarm?.();
|
|
@@ -462,7 +729,7 @@ export class SessionImpl {
|
|
|
462
729
|
const sweep = { at, disarm: undefined };
|
|
463
730
|
const run = () => {
|
|
464
731
|
this.#sweepJobs++;
|
|
465
|
-
this.#
|
|
732
|
+
this.#maintain(async () => {
|
|
466
733
|
if (this.#documentSweep === sweep)
|
|
467
734
|
this.#documentSweep = undefined;
|
|
468
735
|
if (this.#closing === undefined)
|
|
@@ -494,6 +761,9 @@ export class SessionImpl {
|
|
|
494
761
|
for (const [addressId, since] of this.#idleDocuments) {
|
|
495
762
|
if (since > expired)
|
|
496
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;
|
|
497
767
|
this.#dropDocument(addressId);
|
|
498
768
|
}
|
|
499
769
|
this.#scheduleDocumentSweep();
|
|
@@ -517,7 +787,7 @@ export class SessionImpl {
|
|
|
517
787
|
this.#touchDocument(addressId);
|
|
518
788
|
};
|
|
519
789
|
}
|
|
520
|
-
async #runCommit(change, context, scope) {
|
|
790
|
+
async #runCommit(change, context, scope, withinMs, index) {
|
|
521
791
|
this.#assertHealthy();
|
|
522
792
|
context.abortSignal?.throwIfAborted();
|
|
523
793
|
const tx = new Transaction(this.#host, context, scope);
|
|
@@ -544,51 +814,249 @@ export class SessionImpl {
|
|
|
544
814
|
if (writes.length === 0) {
|
|
545
815
|
tx.discard();
|
|
546
816
|
this.#sweepDocuments();
|
|
547
|
-
return result;
|
|
817
|
+
return { value: result };
|
|
548
818
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
// Once admitted, caller cancellation does not interrupt Storage settlement.
|
|
552
|
-
seq = await this.#storage.commit(writes, withoutAbortSignal(context));
|
|
553
|
-
}
|
|
554
|
-
catch (error) {
|
|
555
|
-
// The guard has failed the Session.
|
|
819
|
+
if (this.#readOnly) {
|
|
820
|
+
// Refused before Storage: the Session stays healthy, and the caller sees why.
|
|
556
821
|
tx.discard();
|
|
557
|
-
|
|
822
|
+
this.#sweepDocuments();
|
|
823
|
+
throw new ReadOnlySession("a commit with writes");
|
|
558
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
|
+
}
|
|
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) {
|
|
559
876
|
let documents;
|
|
560
877
|
try {
|
|
561
878
|
documents = tx.adopt(seq);
|
|
562
879
|
}
|
|
563
880
|
catch (error) {
|
|
564
|
-
|
|
565
|
-
this.fail(error);
|
|
566
|
-
throw error;
|
|
881
|
+
return { kind: "adoption", error };
|
|
567
882
|
}
|
|
568
|
-
|
|
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)
|
|
569
888
|
this.#touchDocument(addressId(document.record));
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
889
|
+
return failure === undefined ? undefined : { kind: "listener", error: failure.error };
|
|
890
|
+
}
|
|
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
|
+
}
|
|
573
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)
|
|
968
|
+
return;
|
|
969
|
+
const writes = [];
|
|
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
|
+
*/
|
|
574
1045
|
#publish(seq, writes, documents, context) {
|
|
575
1046
|
const changes = this.#commitListeners.size > 0 || this.#internalListeners.size > 0 ? [] : undefined;
|
|
576
1047
|
for (const write of writes) {
|
|
577
1048
|
switch (write.type) {
|
|
578
1049
|
case "entry":
|
|
579
|
-
|
|
580
|
-
"entry.id": write.value.id,
|
|
581
|
-
"entry.kind": write.value.kind,
|
|
582
|
-
});
|
|
1050
|
+
this.#event("pi.durable.entry", { "entry.id": write.value.id, "entry.kind": write.value.kind }, context);
|
|
583
1051
|
changes?.push(write);
|
|
584
1052
|
break;
|
|
585
1053
|
case "submission":
|
|
586
1054
|
if (write.value.status === "done" || write.value.status === "unanswered") {
|
|
587
|
-
|
|
1055
|
+
this.#event("pi.durable.submission", {
|
|
588
1056
|
"submission.id": write.value.id,
|
|
589
1057
|
status: write.value.status,
|
|
590
1058
|
"answer.entry_id": write.value.answer,
|
|
591
|
-
});
|
|
1059
|
+
}, context);
|
|
592
1060
|
}
|
|
593
1061
|
changes?.push(write);
|
|
594
1062
|
break;
|
|
@@ -598,71 +1066,208 @@ export class SessionImpl {
|
|
|
598
1066
|
}
|
|
599
1067
|
}
|
|
600
1068
|
if (changes === undefined)
|
|
601
|
-
return;
|
|
602
|
-
|
|
1069
|
+
return undefined;
|
|
1070
|
+
const tables = changes.length;
|
|
1071
|
+
for (const document of documents.observed)
|
|
603
1072
|
changes.push(document);
|
|
604
|
-
const
|
|
605
|
-
|
|
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;
|
|
606
1083
|
for (const listener of [...this.#internalListeners]) {
|
|
607
|
-
|
|
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);
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
this.report(error, TELEMETRY, context);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
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.
|
|
1118
|
+
*/
|
|
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;
|
|
608
1143
|
}
|
|
609
|
-
|
|
610
|
-
|
|
1144
|
+
finally {
|
|
1145
|
+
signal?.removeEventListener("abort", markCancelled);
|
|
611
1146
|
}
|
|
612
1147
|
}
|
|
613
1148
|
/**
|
|
614
|
-
*
|
|
615
|
-
*
|
|
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.
|
|
616
1159
|
*/
|
|
617
|
-
#
|
|
1160
|
+
#observeDocument(definition, loaded, create, ownerEnded) {
|
|
618
1161
|
checkRecordScope(definition, loaded.record);
|
|
619
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);
|
|
620
1174
|
let unsubscribeCommit = () => { };
|
|
621
1175
|
let unsubscribeClose = () => { };
|
|
622
|
-
|
|
623
|
-
const unpin = this.#pinDocument(loaded.addressId, loaded.record.id);
|
|
1176
|
+
let attached = true;
|
|
624
1177
|
const detach = () => {
|
|
1178
|
+
if (!attached)
|
|
1179
|
+
return;
|
|
1180
|
+
attached = false;
|
|
625
1181
|
unsubscribeCommit();
|
|
626
1182
|
unsubscribeClose();
|
|
627
1183
|
unpin();
|
|
628
1184
|
};
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
observer
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
throw error;
|
|
636
|
-
}
|
|
637
|
-
const observed = { version: loaded.valueVersion };
|
|
638
|
-
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;
|
|
639
1191
|
for (const change of publication.changes) {
|
|
640
|
-
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
|
+
}
|
|
641
1199
|
continue;
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
// A migration-only base changes nothing for an observer of the new version.
|
|
646
|
-
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")
|
|
647
1203
|
continue;
|
|
648
|
-
|
|
649
|
-
|
|
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;
|
|
650
1213
|
unpin();
|
|
651
|
-
|
|
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;
|
|
652
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)));
|
|
653
1248
|
});
|
|
654
|
-
|
|
655
|
-
return { observer, detach };
|
|
1249
|
+
return observer;
|
|
656
1250
|
}
|
|
657
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;
|
|
658
1256
|
const cached = this.#documents.get(addressId);
|
|
659
1257
|
// A tracker serves only tokens of the version its value was materialized for; others reload from Storage.
|
|
660
1258
|
if (cached?.valueVersion === definition.version) {
|
|
661
1259
|
this.#touchDocument(addressId);
|
|
662
1260
|
return cached;
|
|
663
1261
|
}
|
|
664
|
-
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);
|
|
665
1269
|
this.#dropDocument(addressId);
|
|
1270
|
+
}
|
|
666
1271
|
const record = await this.#storage.findDocument(address, "current", context);
|
|
667
1272
|
if (record === undefined)
|
|
668
1273
|
return undefined;
|
|
@@ -682,10 +1287,139 @@ export class SessionImpl {
|
|
|
682
1287
|
this.#touchDocument(addressId);
|
|
683
1288
|
return loaded;
|
|
684
1289
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
+
}
|
|
689
1423
|
}
|
|
690
1424
|
/** Internal: throw `SessionFailed` when failed, else an error when closing. */
|
|
691
1425
|
assertUsable() {
|
|
@@ -714,16 +1448,123 @@ export function contained(callback, onError) {
|
|
|
714
1448
|
}
|
|
715
1449
|
}
|
|
716
1450
|
/**
|
|
717
|
-
*
|
|
718
|
-
* 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.
|
|
719
1532
|
*/
|
|
720
|
-
function
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
+
}
|
|
727
1568
|
}
|
|
728
1569
|
function cancellationError(signal) {
|
|
729
1570
|
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
@@ -745,9 +1586,26 @@ class GuardedStorage {
|
|
|
745
1586
|
this.#storage = storage;
|
|
746
1587
|
this.#latch = latch;
|
|
747
1588
|
}
|
|
748
|
-
/**
|
|
749
|
-
|
|
750
|
-
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
|
+
}
|
|
751
1609
|
}
|
|
752
1610
|
mintId() {
|
|
753
1611
|
return this.#call(undefined, () => this.#storage.mintId());
|