@earendil-works/pi-durable 1.4.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 +186 -0
- package/README.md +181 -84
- package/dist/codemode/codemode.d.ts +4 -4
- package/dist/codemode/codemode.d.ts.map +1 -1
- package/dist/codemode/codemode.js +9 -12
- package/dist/codemode/codemode.js.map +1 -1
- package/dist/codemode/models.js +4 -4
- package/dist/codemode/models.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 +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 +10 -2
- package/dist/harness/agent.js.map +1 -1
- package/dist/harness/compaction.d.ts.map +1 -1
- package/dist/harness/compaction.js +19 -2
- 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 +445 -96
- package/dist/harness/generation.js.map +1 -1
- package/dist/harness/harness.d.ts +2 -0
- 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 +5 -1
- package/dist/harness/inbox.d.ts.map +1 -1
- package/dist/harness/inbox.js +4 -1
- 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/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 +46 -19
- package/dist/harness/scheduler.d.ts.map +1 -1
- package/dist/harness/scheduler.js +973 -376
- package/dist/harness/scheduler.js.map +1 -1
- package/dist/harness/submissions.d.ts +17 -10
- package/dist/harness/submissions.d.ts.map +1 -1
- package/dist/harness/submissions.js +62 -25
- package/dist/harness/submissions.js.map +1 -1
- package/dist/harness/task-graph.d.ts +12 -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 +2 -0
- package/dist/harness/tool.d.ts.map +1 -1
- package/dist/harness/tool.js +171 -55
- 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 +218 -59
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/util.d.ts +3 -1
- 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 +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 +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 +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 +167 -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 +81 -3
- package/dist/session/transaction.d.ts.map +1 -1
- package/dist/session/transaction.js +224 -37
- 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 +10 -2
- 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/storage.d.ts +14 -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 +28 -2
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite/storage.d.ts +14 -2
- 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 +354 -69
- package/dist/testing/storage-conformance.js.map +1 -1
- 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 +223 -77
- 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
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { copyJson } from "@earendil-works/chord";
|
|
2
2
|
import { awaitWithContext, withAbortSignal, withoutAbortSignal, withTelemetry } from "@earendil-works/chord/context";
|
|
3
|
+
import { ownedSlice } from "@earendil-works/chord/delta";
|
|
3
4
|
import { createTypedMetrics, createTypedSpanStarter, NOOP_TELEMETRY_CONTEXT, NOOP_TELEMETRY_METRICS, } from "@earendil-works/pi-telemetry";
|
|
4
|
-
import { errorMessage } from "../errors.js";
|
|
5
|
+
import { errorMessage, ReadOnlySession } from "../errors.js";
|
|
6
|
+
import { ALL_KEYS, conversationKey, INTENT_KEY, ownedKey, ROOTS_KEY, taskKey } from "../session/keys.js";
|
|
5
7
|
import { agentHooks, DEFAULT_RESTART_LIMIT } from "./agent.js";
|
|
6
|
-
import { readContextFrom } from "./context.js";
|
|
8
|
+
import { advanceRange, rangeEntry, readContextFrom } from "./context.js";
|
|
7
9
|
import { DURABLE_TELEMETRY_SCHEMA } from "./telemetry.js";
|
|
8
|
-
import { closedError, optionalExtension, scanAll, Waiters } from "./util.js";
|
|
10
|
+
import { closedError, conversationEntries, optionalExtension, scanAll, Waiters } from "./util.js";
|
|
9
11
|
const SCAN_PAGE_SIZE = 256;
|
|
12
|
+
const NO_KEYS = [];
|
|
10
13
|
const LIVE_STATUSES = ["pending", "running", "waiting", "sleeping", "completing"];
|
|
11
14
|
/** The part of a record `blockedReason()` reads. */
|
|
12
15
|
export function blockedSubject(record) {
|
|
@@ -16,10 +19,17 @@ export function blockedSubject(record) {
|
|
|
16
19
|
version: record.version,
|
|
17
20
|
abortRequested: record.abortRequested,
|
|
18
21
|
...(record.abortReason === undefined ? {} : { abortReason: record.abortReason }),
|
|
19
|
-
...(record.restarts === undefined ? {} : { restarts: record.restarts }),
|
|
20
22
|
status: record.state.status,
|
|
21
23
|
};
|
|
22
24
|
}
|
|
25
|
+
/** Continue with the next phase, the first one. */
|
|
26
|
+
const CONTINUE = { kind: "continue" };
|
|
27
|
+
/** Rule 5: the phase made durable progress; `#progress()` decides once the decision stands. */
|
|
28
|
+
const PROGRESS = { kind: "progress" };
|
|
29
|
+
/** End the invocation without a write. */
|
|
30
|
+
const END = { kind: "end" };
|
|
31
|
+
/** End it: an owner's cancellation intent reaches it before its cascade did; schedule the cascade. */
|
|
32
|
+
const CASCADE = { kind: "cascade" };
|
|
23
33
|
function conversationNode(id) {
|
|
24
34
|
return `c${id}`;
|
|
25
35
|
}
|
|
@@ -48,9 +58,16 @@ function parentNode(node) {
|
|
|
48
58
|
* later below a dropped node finds it unknown and loads its chain from storage again.
|
|
49
59
|
*
|
|
50
60
|
* Invariant: every task transition is decided and written by one callback serialized on the Session line. That covers
|
|
51
|
-
* reservation, marks, runtime commits, finalization, and the
|
|
52
|
-
*
|
|
53
|
-
*
|
|
61
|
+
* reservation, marks, runtime commits, finalization, and the step before each phase, which applies the precedence rules
|
|
62
|
+
* and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the step that decides
|
|
63
|
+
* its end, so a runtime commit it queued either lands before that decision or is rejected.
|
|
64
|
+
*
|
|
65
|
+
* Decisions stay serialized; their storage overlaps (group commit). The Session stores the commits of several jobs in
|
|
66
|
+
* one call while the line runs on, and this memory changes only as each commit is published. So every decision reads
|
|
67
|
+
* it in a decision region (`#region()`): synchronous, recording the keys it reads (a task's record, the live work below
|
|
68
|
+
* a node, the cancellation intent set), staging nothing; when one of them has an unpublished writer, the region waits
|
|
69
|
+
* for its publication and runs again, and only a region that read nothing unpublished is applied. Every commit writes
|
|
70
|
+
* the memberships its task changes alter (`membershipKeys()`), so a walk that a pending commit would change waits.
|
|
54
71
|
*/
|
|
55
72
|
export class TaskScheduler {
|
|
56
73
|
#telemetry;
|
|
@@ -70,6 +87,7 @@ export class TaskScheduler {
|
|
|
70
87
|
#writeAtClose;
|
|
71
88
|
#report;
|
|
72
89
|
#settleOutcome;
|
|
90
|
+
#limitsRestarts;
|
|
73
91
|
#withdrawInputs;
|
|
74
92
|
#conversation;
|
|
75
93
|
#context;
|
|
@@ -80,6 +98,20 @@ export class TaskScheduler {
|
|
|
80
98
|
* first idle check after `settings.contextRetentionMs` of idleness, and at close.
|
|
81
99
|
*/
|
|
82
100
|
#contexts = new Map();
|
|
101
|
+
/**
|
|
102
|
+
* The idle kept contexts by `idleSince`, oldest first: the expiry order, so neither an expiry nor finding the next one
|
|
103
|
+
* scans `#contexts`. Kept sorted also when the Harness clock goes back (see `#markIdle`).
|
|
104
|
+
*/
|
|
105
|
+
#idleOrder = new Map();
|
|
106
|
+
/** At least the newest `idleSince` in `#idleOrder` (drops do not lower it): an older one added means the clock went back. */
|
|
107
|
+
#idleNewest = Number.NEGATIVE_INFINITY;
|
|
108
|
+
/**
|
|
109
|
+
* A chain load resolved what `#idle()` treats as unknown for a live task whose chain is not loaded, which can make a
|
|
110
|
+
* scope idle without a task change below it; the next idle check then covers every scope.
|
|
111
|
+
*/
|
|
112
|
+
#unknownsChanged = false;
|
|
113
|
+
/** Idle checks that covered every scope (`idleState`, a test diagnostic). */
|
|
114
|
+
#fullIdleChecks = 0;
|
|
83
115
|
/** Background timer for the earliest expiry of an idle context, unless the host declined it (see `#scheduleExpiry`). */
|
|
84
116
|
#expiry;
|
|
85
117
|
#invocations = new Map();
|
|
@@ -133,15 +165,21 @@ export class TaskScheduler {
|
|
|
133
165
|
#blockedListeners = new Set();
|
|
134
166
|
/** A registry snapshot that threw for the task graph was reported; it is read per record, so only once. */
|
|
135
167
|
#snapshotReported = false;
|
|
136
|
-
/** Tasks a pass left out as parked; each pass checks them again, as `settings.restartLimit` may have risen. */
|
|
137
|
-
#parkedTasks = new Set();
|
|
138
168
|
#reconcileScheduled = false;
|
|
139
169
|
/** Reconcile passes underway with work (they may overlap): their commits finalize held outcomes and mark cascades. */
|
|
140
170
|
#reconcilingWork = 0;
|
|
141
171
|
#sweepScheduled = false;
|
|
172
|
+
/**
|
|
173
|
+
* Nodes chain loads installed for jobs whose commits are not published yet, with how many such jobs: the sweep keeps
|
|
174
|
+
* them, as work a pending commit creates below them needs them.
|
|
175
|
+
*/
|
|
176
|
+
#pins = new Map();
|
|
177
|
+
/** Keys the decision region underway reads (`#region()`); set only while one runs. */
|
|
178
|
+
#reading;
|
|
142
179
|
#cascadePending = false;
|
|
143
180
|
#unsubscribeRegistry = () => { };
|
|
144
181
|
#enabled = false;
|
|
182
|
+
#readOnly;
|
|
145
183
|
#closing = false;
|
|
146
184
|
#dirty = false;
|
|
147
185
|
#draining = false;
|
|
@@ -163,24 +201,26 @@ export class TaskScheduler {
|
|
|
163
201
|
this.#writeAtClose = options.writeAtClose;
|
|
164
202
|
this.#report = options.report;
|
|
165
203
|
this.#settleOutcome = options.settleOutcome;
|
|
204
|
+
this.#limitsRestarts = options.limitsRestarts;
|
|
166
205
|
this.#withdrawInputs = options.withdrawInputs;
|
|
167
206
|
this.#conversation = options.conversation;
|
|
168
207
|
this.#context = options.context;
|
|
208
|
+
this.#readOnly = options.readOnly === true;
|
|
169
209
|
}
|
|
170
210
|
/**
|
|
171
|
-
* Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing
|
|
172
|
-
* task
|
|
211
|
+
* Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing;
|
|
212
|
+
* a task cut off `settings.restartLimit` times ends when it is next reserved. Read-only, it only loads them: a
|
|
213
|
+
* `running` task stays as committed, running in another process.
|
|
173
214
|
*/
|
|
174
215
|
async open(context) {
|
|
175
|
-
this.#session.
|
|
216
|
+
this.#session.followCommits((publication) => this.#observe(publication));
|
|
176
217
|
this.#session.subscribeClose(() => this.#seal());
|
|
177
218
|
this.#unsubscribeRegistry = this.#registry.subscribe(() => {
|
|
178
219
|
this.#notifyBlocked();
|
|
179
220
|
this.#kick();
|
|
180
221
|
});
|
|
181
|
-
const limit = this.#restartLimit();
|
|
182
|
-
const parked = [];
|
|
183
222
|
await this.#session.commitWith(async (tx) => {
|
|
223
|
+
await this.#published();
|
|
184
224
|
// Every table read before the first write.
|
|
185
225
|
const scans = [];
|
|
186
226
|
for (const status of LIVE_STATUSES) {
|
|
@@ -190,14 +230,14 @@ export class TaskScheduler {
|
|
|
190
230
|
for (const record of records) {
|
|
191
231
|
this.#track(undefined, record);
|
|
192
232
|
this.#resumed.add(record.id);
|
|
233
|
+
if (this.#readOnly)
|
|
234
|
+
continue;
|
|
193
235
|
if (record.abandonOnRestart === true)
|
|
194
236
|
this.#abandoned.add(record.id);
|
|
195
237
|
if (record.state.status === "running") {
|
|
196
238
|
const restarts = (record.restarts ?? 0) + 1;
|
|
197
239
|
const pending = withState(record, { status: "pending", checkpoint: record.state.checkpoint });
|
|
198
240
|
tx.setTask({ ...pending, restarts });
|
|
199
|
-
if (restarts >= limit && !record.abortRequested)
|
|
200
|
-
parked.push(record);
|
|
201
241
|
}
|
|
202
242
|
if (record.state.status === "waiting" && record.state.policy === "failFast") {
|
|
203
243
|
this.#failFastChecks.add(record.id);
|
|
@@ -205,11 +245,8 @@ export class TaskScheduler {
|
|
|
205
245
|
}
|
|
206
246
|
}
|
|
207
247
|
}, context);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const error = new Error(`Task ${record.id} (${record.kind}) was cut off ${restarts} times without progress; it is parked until retryTask()`);
|
|
211
|
-
this.#report(error, { type: "task_parked", ...this.#aboutTask(recordTask(record)) });
|
|
212
|
-
}
|
|
248
|
+
if (this.#readOnly)
|
|
249
|
+
return;
|
|
213
250
|
// Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes.
|
|
214
251
|
this.#cascadePending = true;
|
|
215
252
|
this.#scheduleReconcile();
|
|
@@ -220,29 +257,15 @@ export class TaskScheduler {
|
|
|
220
257
|
* Idempotent; the kick does nothing once closing.
|
|
221
258
|
*/
|
|
222
259
|
resume() {
|
|
260
|
+
if (this.#readOnly)
|
|
261
|
+
throw new ReadOnlySession("running tasks");
|
|
223
262
|
this.#enabled = true;
|
|
224
263
|
this.#wakeDue();
|
|
225
264
|
this.#kick();
|
|
226
265
|
}
|
|
227
266
|
/**
|
|
228
|
-
*
|
|
229
|
-
* task
|
|
230
|
-
*/
|
|
231
|
-
async retry(id, context) {
|
|
232
|
-
await this.#session.commitWith(async (tx) => {
|
|
233
|
-
const current = await tx.task(id);
|
|
234
|
-
if (current === undefined)
|
|
235
|
-
throw new Error(`Task ${id} does not exist`);
|
|
236
|
-
const status = current.state.status;
|
|
237
|
-
if (status === "terminal" || status === "completing" || !this.#parked(current))
|
|
238
|
-
return;
|
|
239
|
-
tx.setTask(progressed(current));
|
|
240
|
-
}, context);
|
|
241
|
-
this.resume();
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* Why a live task cannot run here, if it cannot: its definition (missing, too old, failed migration) or the restart
|
|
245
|
-
* limit. A running task, an abort-marked one, and one holding its outcome are never blocked.
|
|
267
|
+
* Why a live task cannot run here, if it cannot: its definition is missing, too old, or failed to migrate. A running
|
|
268
|
+
* task, an abort-marked one, and one holding its outcome are never blocked.
|
|
246
269
|
*/
|
|
247
270
|
blockedReason(record) {
|
|
248
271
|
const status = record.status;
|
|
@@ -251,8 +274,6 @@ export class TaskScheduler {
|
|
|
251
274
|
// An abort request runs its handler or orphans the task; a `restart` mark waits for the definition, blocked.
|
|
252
275
|
if (record.abortRequested && record.abortReason === undefined)
|
|
253
276
|
return undefined;
|
|
254
|
-
if (this.#parked(record))
|
|
255
|
-
return "restart_limit";
|
|
256
277
|
let snapshot;
|
|
257
278
|
try {
|
|
258
279
|
snapshot = this.#registry.snapshot();
|
|
@@ -278,11 +299,26 @@ export class TaskScheduler {
|
|
|
278
299
|
* sleep not yet due. Synchronous, from memory; a task whose chain is not loaded yet counts as it would alone.
|
|
279
300
|
*/
|
|
280
301
|
wake() {
|
|
302
|
+
// A read-only Harness runs nothing, so it never needs a process.
|
|
303
|
+
if (this.#readOnly)
|
|
304
|
+
return { active: false };
|
|
281
305
|
const now = this.#now();
|
|
282
306
|
// A reconcile pass with work to do is work too: it finalizes held outcomes and marks cascades, at open as
|
|
283
307
|
// recovery. A process that dies before it lands must be woken again. A pass with nothing to do is not.
|
|
284
|
-
|
|
308
|
+
const active = this.#invocations.size > 0 || this.#reconcilingWork > 0 || this.#reconcileWork() || this.#runsNow(now);
|
|
309
|
+
// The earliest sleep not yet due: only `#sleeping` holds sleeping tasks, so no pass visits every live task.
|
|
285
310
|
let at;
|
|
311
|
+
for (const id of this.#sleeping) {
|
|
312
|
+
const record = this.#live.get(id);
|
|
313
|
+
const state = record?.state;
|
|
314
|
+
if (state?.status === "sleeping" && !record.abortRequested && state.until > now) {
|
|
315
|
+
at = at === undefined ? state.until : Math.min(at, state.until);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return at === undefined ? { active } : { active, at };
|
|
319
|
+
}
|
|
320
|
+
/** Whether a live task can run now: the scan `wake()` needs only while no invocation runs. */
|
|
321
|
+
#runsNow(now) {
|
|
286
322
|
let snapshot;
|
|
287
323
|
let snapshotFailed = false;
|
|
288
324
|
for (const record of this.#live.values()) {
|
|
@@ -291,13 +327,7 @@ export class TaskScheduler {
|
|
|
291
327
|
continue;
|
|
292
328
|
if (state.status === "waiting" && !record.abortRequested && state.on.some((id) => this.#live.has(id)))
|
|
293
329
|
continue;
|
|
294
|
-
if (state.status === "sleeping" && !record.abortRequested && state.until > now)
|
|
295
|
-
at = at === undefined ? state.until : Math.min(at, state.until);
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
if (active)
|
|
299
|
-
continue;
|
|
300
|
-
if (this.#parked(record))
|
|
330
|
+
if (state.status === "sleeping" && !record.abortRequested && state.until > now)
|
|
301
331
|
continue;
|
|
302
332
|
// An abort invocation waits for the live work below it, which counts itself.
|
|
303
333
|
if (record.abortRequested && this.#waitingOn(record, true).length > 0)
|
|
@@ -316,9 +346,9 @@ export class TaskScheduler {
|
|
|
316
346
|
if (snapshot !== undefined && "reason" in this.#fit(record, snapshot.task(record.kind)))
|
|
317
347
|
continue;
|
|
318
348
|
}
|
|
319
|
-
|
|
349
|
+
return true;
|
|
320
350
|
}
|
|
321
|
-
return
|
|
351
|
+
return false;
|
|
322
352
|
}
|
|
323
353
|
/**
|
|
324
354
|
* Signal every invocation and wait for them, then write the tasks they ran back as `pending` (`writeAtClose`), as a
|
|
@@ -363,12 +393,14 @@ export class TaskScheduler {
|
|
|
363
393
|
return { result: "marked" };
|
|
364
394
|
if (invocation === undefined && current.state.status !== "completing") {
|
|
365
395
|
await this.#loadScopes(false);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
396
|
+
const owns = await this.#region(() => this.#hasOwnedLive(id), this.#reloadScopes);
|
|
397
|
+
// Resolved once that decision stands, as it may run `migrate`.
|
|
398
|
+
const resolution = owns
|
|
399
|
+
? undefined
|
|
400
|
+
: this.#resolve(current, this.#registry.snapshot());
|
|
401
|
+
if (resolution?.kind === "blocked") {
|
|
402
|
+
await this.#terminate(tx, current, { status: "orphaned", reason: resolution.reason });
|
|
403
|
+
return { result: "marked" };
|
|
372
404
|
}
|
|
373
405
|
}
|
|
374
406
|
if (!current.abortRequested || current.abortReason !== undefined)
|
|
@@ -384,12 +416,13 @@ export class TaskScheduler {
|
|
|
384
416
|
}
|
|
385
417
|
async waitForTask(id, context) {
|
|
386
418
|
// Check and register on the line so no terminal publication falls between them.
|
|
387
|
-
const found = await this.#session.readOnLine(async () => {
|
|
419
|
+
const found = await this.#session.readOnLine(async (storage) => {
|
|
388
420
|
if (this.#closing)
|
|
389
421
|
throw closedError(this.#session);
|
|
390
|
-
if (this.#
|
|
422
|
+
if (await this.#region(() => this.#liveRecord(id) !== undefined)) {
|
|
391
423
|
return { promise: this.#taskWaiters.add(id, context) };
|
|
392
|
-
|
|
424
|
+
}
|
|
425
|
+
const record = await storage.task(id, context);
|
|
393
426
|
if (record === undefined)
|
|
394
427
|
throw new Error(`Task ${id} does not exist`);
|
|
395
428
|
return { promise: Promise.resolve(record) };
|
|
@@ -415,6 +448,7 @@ export class TaskScheduler {
|
|
|
415
448
|
*/
|
|
416
449
|
async abortConversation(conversationId, background, context) {
|
|
417
450
|
const reached = await this.#session.commitWith(async (tx) => {
|
|
451
|
+
await this.#published();
|
|
418
452
|
const queued = await this.#loadScopes(true);
|
|
419
453
|
const scope = { conversation: conversationId };
|
|
420
454
|
const reached = [];
|
|
@@ -437,11 +471,100 @@ export class TaskScheduler {
|
|
|
437
471
|
await this.waitForTask(id, context);
|
|
438
472
|
await this.waitForIdle(conversationId, context);
|
|
439
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* The ownership memberships a commit's task and conversation changes write (group commit §4.2), at its admission on
|
|
476
|
+
* the line: for each task created or made terminal, every node from its parent up to and including the nearest live
|
|
477
|
+
* task, or the root conversation, and then the roots too, conservatively, as a pending end may unlist the root a
|
|
478
|
+
* creation finds listed; for each conversation created under a task, that task; and the intent set when
|
|
479
|
+
* a change sets, upgrades, or clears a mark or a held or ended outcome other than `completed`. Loads the chains it
|
|
480
|
+
* walks; the nodes it installs stay until the commit is published.
|
|
481
|
+
*/
|
|
482
|
+
membershipKeys(tx) {
|
|
483
|
+
const tasks = tx.stagedTasks();
|
|
484
|
+
const conversations = tx.stagedConversations();
|
|
485
|
+
if (tasks.length === 0 && conversations.every((record) => record.owner === undefined))
|
|
486
|
+
return NO_KEYS;
|
|
487
|
+
return this.#membershipKeys(tx, tasks, conversations);
|
|
488
|
+
}
|
|
489
|
+
async #membershipKeys(tx, tasks, conversations) {
|
|
490
|
+
const keys = new Set();
|
|
491
|
+
const overlay = overlayOf(tx);
|
|
492
|
+
const created = new Set(tx.createdTaskIds());
|
|
493
|
+
const reached = new Map();
|
|
494
|
+
for (const record of tasks) {
|
|
495
|
+
if (intentChanges(this.#live.get(record.id), record))
|
|
496
|
+
keys.add(INTENT_KEY);
|
|
497
|
+
if (!created.has(record.id) && record.state.status !== "terminal")
|
|
498
|
+
continue;
|
|
499
|
+
await this.#loadChain(parentOf(record), overlay);
|
|
500
|
+
if ((await this.#chainKeys(parentNode(record), overlay, keys, reached)) !== undefined)
|
|
501
|
+
keys.add(ROOTS_KEY);
|
|
502
|
+
}
|
|
503
|
+
for (const conversation of conversations) {
|
|
504
|
+
const owner = conversation.owner?.taskId;
|
|
505
|
+
if (owner === undefined)
|
|
506
|
+
continue;
|
|
507
|
+
await this.#loadChain({ task: owner }, overlay);
|
|
508
|
+
await this.#chainKeys(owner, overlay, keys, reached);
|
|
509
|
+
}
|
|
510
|
+
return [...keys];
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Add the memberships from `start` up to and including the nearest live task, or the root conversation, which it
|
|
514
|
+
* returns. `reached` keeps, per node walked, where its walk ended, so a chain several tasks of a commit share is
|
|
515
|
+
* walked once. Whether an ancestor is live can change under a pending commit, so it is read after a touch of the
|
|
516
|
+
* ancestor's record: a pending end must not stop the chain early. Ownership fields are immutable, so not keyed.
|
|
517
|
+
*/
|
|
518
|
+
async #chainKeys(start, overlay, keys, reached) {
|
|
519
|
+
const walked = [];
|
|
520
|
+
let root;
|
|
521
|
+
let node = start;
|
|
522
|
+
while (node !== undefined && node !== null) {
|
|
523
|
+
const key = ownedKey(node);
|
|
524
|
+
// Walked up from here already, for another task of the commit.
|
|
525
|
+
if (reached.has(key)) {
|
|
526
|
+
root = reached.get(key);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
keys.add(key);
|
|
530
|
+
walked.push(key);
|
|
531
|
+
if (typeof node === "string") {
|
|
532
|
+
const conversation = conversationOf(node);
|
|
533
|
+
node = this.#edge(conversation, overlay);
|
|
534
|
+
if (node === null)
|
|
535
|
+
root = conversation;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
let record = overlay.tasks.get(node);
|
|
539
|
+
if (record === undefined) {
|
|
540
|
+
const gate = this.#session.touch(taskKey(node));
|
|
541
|
+
if (gate !== undefined) {
|
|
542
|
+
await gate;
|
|
543
|
+
// It may have ended meanwhile, and its node been swept: load the chain above it again.
|
|
544
|
+
await this.#loadChain({ task: node }, overlay);
|
|
545
|
+
}
|
|
546
|
+
record = this.#live.get(node);
|
|
547
|
+
}
|
|
548
|
+
if (record !== undefined && record.state.status !== "terminal")
|
|
549
|
+
break;
|
|
550
|
+
const above = record ?? this.#settled.get(node);
|
|
551
|
+
node = above === undefined ? undefined : parentNode(above);
|
|
552
|
+
}
|
|
553
|
+
for (const key of walked)
|
|
554
|
+
reached.set(key, root);
|
|
555
|
+
return root;
|
|
556
|
+
}
|
|
440
557
|
// ─── Scheduling ────────────────────────────────────────────────────────
|
|
441
558
|
#observe(publication) {
|
|
559
|
+
// A commit held in memory changes documents only.
|
|
560
|
+
if (publication.kind === "memory")
|
|
561
|
+
return;
|
|
562
|
+
this.#advanceContexts(publication);
|
|
442
563
|
const updated = [];
|
|
443
564
|
const failed = [];
|
|
444
565
|
let changed = false;
|
|
566
|
+
// The conversations whose idleness the commit's task changes can change; undefined when a chain is not known.
|
|
567
|
+
let dirty = new Set();
|
|
445
568
|
// Edges first, so the tasks a commit creates in a new conversation hang from it.
|
|
446
569
|
for (const change of publication.changes) {
|
|
447
570
|
if (change.type === "conversation" && !this.#edges.has(change.value.id)) {
|
|
@@ -457,6 +580,11 @@ export class TaskScheduler {
|
|
|
457
580
|
if (failedOutcome(record) && (previous === undefined || !failedOutcome(previous)))
|
|
458
581
|
failed.push(record.id);
|
|
459
582
|
this.#track(previous, record);
|
|
583
|
+
// Idleness counts live tasks, so only a task that became live or ended can change it. Collected before `#sweep()`,
|
|
584
|
+
// which may forget the chain of a task that ended.
|
|
585
|
+
const liveness = previous === undefined || record.state.status === "terminal";
|
|
586
|
+
if (liveness && dirty !== undefined && !this.#addScopesAbove(record, dirty))
|
|
587
|
+
dirty = undefined;
|
|
460
588
|
if (record.state.status === "terminal") {
|
|
461
589
|
this.#failedMigrations.delete(record.id);
|
|
462
590
|
this.#failFastChecks.delete(record.id);
|
|
@@ -467,10 +595,11 @@ export class TaskScheduler {
|
|
|
467
595
|
}
|
|
468
596
|
if (record.abortRequested && previous?.abortRequested !== true) {
|
|
469
597
|
this.#cascadePending = true;
|
|
470
|
-
// Signal a run invocation of the newly marked task; its next step ends it.
|
|
598
|
+
// Signal a run invocation of the newly marked task; its next step ends it. Abort listeners are task code, which
|
|
599
|
+
// never runs inside a publication: the signal follows in a microtask, after host commit listeners.
|
|
471
600
|
const invocation = this.#invocations.get(record.id);
|
|
472
601
|
if (invocation?.mode === "run")
|
|
473
|
-
invocation.controller.abort();
|
|
602
|
+
queueMicrotask(() => invocation.controller.abort());
|
|
474
603
|
}
|
|
475
604
|
// A request replacing a `restart` mark upgrades the marks of the work below.
|
|
476
605
|
if (previous?.abortReason !== undefined && record.abortReason === undefined)
|
|
@@ -524,9 +653,60 @@ export class TaskScheduler {
|
|
|
524
653
|
this.#sweep();
|
|
525
654
|
if (!changed)
|
|
526
655
|
return;
|
|
527
|
-
this.#settleIdle();
|
|
656
|
+
this.#settleIdle(dirty);
|
|
528
657
|
this.#kick();
|
|
529
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* Add the conversations above `record` to `scopes`: those whose idleness a change of it can change. False when a node
|
|
661
|
+
* on the way up is not known. A conversation already in `scopes` has everything above it there too.
|
|
662
|
+
*/
|
|
663
|
+
#addScopesAbove(record, scopes) {
|
|
664
|
+
for (const step of this.#above(parentOf(record))) {
|
|
665
|
+
if ("unknown" in step)
|
|
666
|
+
return false;
|
|
667
|
+
if (!("conversation" in step))
|
|
668
|
+
continue;
|
|
669
|
+
if (scopes.has(step.conversation))
|
|
670
|
+
return true;
|
|
671
|
+
scopes.add(step.conversation);
|
|
672
|
+
}
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Extend each kept context range with the entries a stored commit appended to its conversation, so a later read
|
|
677
|
+
* needs no Storage scan. Entries are published in ID order.
|
|
678
|
+
*/
|
|
679
|
+
#advanceContexts(publication) {
|
|
680
|
+
if (this.#contexts.size === 0)
|
|
681
|
+
return;
|
|
682
|
+
let appended;
|
|
683
|
+
for (const change of publication.changes) {
|
|
684
|
+
if (change.type !== "entry" || !this.#contexts.has(change.value.conversationId))
|
|
685
|
+
continue;
|
|
686
|
+
appended ??= new Map();
|
|
687
|
+
const entries = appended.get(change.value.conversationId);
|
|
688
|
+
if (entries === undefined)
|
|
689
|
+
appended.set(change.value.conversationId, [change.value]);
|
|
690
|
+
else
|
|
691
|
+
entries.push(change.value);
|
|
692
|
+
}
|
|
693
|
+
if (appended === undefined)
|
|
694
|
+
return;
|
|
695
|
+
for (const [conversationId, entries] of appended) {
|
|
696
|
+
const kept = this.#contexts.get(conversationId);
|
|
697
|
+
// An idle range past its retention is dropped here too: entry-only commits do not run `#settleIdle()`, and a
|
|
698
|
+
// host may decline the expiry timer. `expiresAt` was fixed when it went idle, so no settings are read here.
|
|
699
|
+
if (kept.idleSince !== undefined && kept.expiresAt !== undefined && kept.expiresAt <= this.#now()) {
|
|
700
|
+
this.#dropContext(conversationId);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const range = advanceRange(kept.range, entries);
|
|
704
|
+
if (range === undefined)
|
|
705
|
+
this.#dropContext(conversationId);
|
|
706
|
+
else
|
|
707
|
+
kept.range = range;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
530
710
|
/**
|
|
531
711
|
* `settings.contextRetentionMs`, or 0 when the host's settings throw. Kept contexts are only a cache, so dropping them
|
|
532
712
|
* is safe, while a throw here would escape commit listeners, reconciliation, and the expiry timer.
|
|
@@ -540,43 +720,94 @@ export class TaskScheduler {
|
|
|
540
720
|
return 0;
|
|
541
721
|
}
|
|
542
722
|
}
|
|
543
|
-
/**
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
723
|
+
/**
|
|
724
|
+
* After task changes below the conversations in `scopes` (every conversation when undefined): resolve their idle
|
|
725
|
+
* waiters and the Harness-wide ones, drop the kept contexts whose idle period passed the retention, then start or end
|
|
726
|
+
* the idle periods of the kept contexts in `scopes`. A task change can change the idleness only of the conversations
|
|
727
|
+
* above it, so no check visits every conversation.
|
|
728
|
+
*/
|
|
729
|
+
#settleIdle(scopes) {
|
|
730
|
+
const all = scopes === undefined || this.#unknownsChanged;
|
|
731
|
+
this.#unknownsChanged = false;
|
|
732
|
+
if (all)
|
|
733
|
+
this.#fullIdleChecks++;
|
|
734
|
+
this.#resolveIdleWaiters(all ? undefined : scopes);
|
|
549
735
|
const now = this.#now();
|
|
550
736
|
const retention = this.#contextRetentionMs();
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
737
|
+
// Expired first: a context past its retention is dropped even when the same commit makes it busy again.
|
|
738
|
+
this.#expireContexts(now, retention);
|
|
739
|
+
for (const conversationId of all ? [...this.#contexts.keys()] : scopes) {
|
|
740
|
+
const kept = this.#contexts.get(conversationId);
|
|
741
|
+
if (kept === undefined)
|
|
742
|
+
continue;
|
|
743
|
+
if (!this.#idle(conversationId)) {
|
|
744
|
+
if (kept.idleSince === undefined)
|
|
745
|
+
continue;
|
|
556
746
|
kept.idleSince = undefined;
|
|
747
|
+
this.#idleOrder.delete(conversationId);
|
|
557
748
|
}
|
|
558
|
-
else if (retention
|
|
559
|
-
|
|
749
|
+
else if (retention <= 0) {
|
|
750
|
+
this.#dropContext(conversationId);
|
|
560
751
|
}
|
|
561
|
-
else {
|
|
562
|
-
|
|
752
|
+
else if (kept.idleSince === undefined) {
|
|
753
|
+
kept.idleSince = now;
|
|
754
|
+
kept.expiresAt = now + retention;
|
|
755
|
+
this.#markIdle(conversationId, now);
|
|
563
756
|
}
|
|
564
757
|
}
|
|
565
758
|
this.#scheduleExpiry();
|
|
566
759
|
}
|
|
760
|
+
/** Resolve the idle waiters of the conversations in `scopes` (all when undefined) and the Harness-wide ones. */
|
|
761
|
+
#resolveIdleWaiters(scopes) {
|
|
762
|
+
for (const conversationId of this.#idleWaiters.keys()) {
|
|
763
|
+
if (conversationId !== undefined && scopes !== undefined && !scopes.has(conversationId))
|
|
764
|
+
continue;
|
|
765
|
+
if (this.#idle(conversationId))
|
|
766
|
+
this.#idleWaiters.resolve(conversationId);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
567
769
|
/**
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
* keep a Durable Object from being evicted, and eviction frees the contexts. There, task changes alone check expiry.
|
|
770
|
+
* Drop the kept contexts idle for at least `retention`, oldest first, with the retention setting of now; with no
|
|
771
|
+
* retention, every idle one, also those whose idle period a clock that went back makes start after now.
|
|
571
772
|
*/
|
|
572
|
-
#
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
773
|
+
#expireContexts(now, retention) {
|
|
774
|
+
for (const [conversationId, idleSince] of this.#idleOrder) {
|
|
775
|
+
if (retention > 0 && now - idleSince < retention)
|
|
776
|
+
return;
|
|
777
|
+
this.#dropContext(conversationId);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Start the idle period of a kept context in the expiry order. An idle period older than the newest one kept, which
|
|
782
|
+
* a clock that went back starts, sorts the order again, and the newest is taken from what is kept then.
|
|
783
|
+
*/
|
|
784
|
+
#markIdle(conversationId, idleSince) {
|
|
785
|
+
this.#idleOrder.delete(conversationId);
|
|
786
|
+
this.#idleOrder.set(conversationId, idleSince);
|
|
787
|
+
if (this.#idleOrder.size === 1 || idleSince >= this.#idleNewest) {
|
|
788
|
+
this.#idleNewest = idleSince;
|
|
789
|
+
return;
|
|
579
790
|
}
|
|
791
|
+
const sorted = [...this.#idleOrder].sort((a, b) => a[1] - b[1]);
|
|
792
|
+
this.#idleOrder.clear();
|
|
793
|
+
for (const [id, since] of sorted)
|
|
794
|
+
this.#idleOrder.set(id, since);
|
|
795
|
+
this.#idleNewest = sorted.at(-1)[1];
|
|
796
|
+
}
|
|
797
|
+
/** Forget a kept context, idle or not, and its place in the expiry order. */
|
|
798
|
+
#dropContext(conversationId) {
|
|
799
|
+
this.#contexts.delete(conversationId);
|
|
800
|
+
this.#idleOrder.delete(conversationId);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Drop the expired contexts when the earliest idle context expires, on a background timer, which never keeps the
|
|
804
|
+
* process alive. Where the host declines it, as the default does in Cloudflare Workers, none is kept: a pending timer
|
|
805
|
+
* could keep a Durable Object from being evicted, and eviction frees the contexts. There, task changes alone check
|
|
806
|
+
* expiry.
|
|
807
|
+
*/
|
|
808
|
+
#scheduleExpiry() {
|
|
809
|
+
const oldest = this.#idleOrder.values().next();
|
|
810
|
+
const at = oldest.done === true ? undefined : oldest.value + this.#contextRetentionMs();
|
|
580
811
|
if (this.#expiry !== undefined && this.#expiry.at === at)
|
|
581
812
|
return;
|
|
582
813
|
this.#expiry?.disarm();
|
|
@@ -585,14 +816,16 @@ export class TaskScheduler {
|
|
|
585
816
|
return;
|
|
586
817
|
const disarm = this.#timers.background(() => {
|
|
587
818
|
this.#expiry = undefined;
|
|
588
|
-
this.#
|
|
819
|
+
this.#expireContexts(this.#now(), this.#contextRetentionMs());
|
|
820
|
+
this.#scheduleExpiry();
|
|
589
821
|
}, at - this.#now());
|
|
590
822
|
if (disarm !== undefined)
|
|
591
823
|
this.#expiry = { at, disarm };
|
|
592
824
|
}
|
|
593
825
|
// ─── Ownership ───────────────────────────────────────────────────────────
|
|
594
826
|
#scheduleReconcile() {
|
|
595
|
-
|
|
827
|
+
// A read-only reconcile could only fail: what it writes is refused, and its own commits fail the Session.
|
|
828
|
+
if (this.#reconcileScheduled || this.#closing || this.#readOnly)
|
|
596
829
|
return;
|
|
597
830
|
this.#reconcileScheduled = true;
|
|
598
831
|
queueMicrotask(() => void this.#reconcile());
|
|
@@ -608,54 +841,22 @@ export class TaskScheduler {
|
|
|
608
841
|
const work = this.#reconcileWork();
|
|
609
842
|
if (work)
|
|
610
843
|
this.#reconcilingWork++;
|
|
611
|
-
const cascade = this.#cascadePending;
|
|
612
|
-
this.#cascadePending = false;
|
|
613
|
-
const checks = [...this.#failFastChecks];
|
|
614
|
-
this.#failFastChecks.clear();
|
|
615
844
|
try {
|
|
616
|
-
await this.#session.
|
|
845
|
+
await this.#session.commitInternal(async (tx) => {
|
|
617
846
|
if (this.#closing)
|
|
618
847
|
return;
|
|
848
|
+
// A cascade scans the queued inputs, which waits for every unpublished commit: a request made before the scan
|
|
849
|
+
// is covered by it, and none is published while the job holds the line after it.
|
|
850
|
+
const cascade = this.#cascadePending;
|
|
619
851
|
const queued = await this.#loadScopes(cascade);
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const marks =
|
|
623
|
-
const mark = (record, reason) => {
|
|
624
|
-
const staged = marks.get(record.id);
|
|
625
|
-
if (staged !== undefined && staged.reason === undefined)
|
|
626
|
-
return;
|
|
627
|
-
if (record.abortRequested && (record.abortReason === undefined || reason !== undefined))
|
|
628
|
-
return;
|
|
629
|
-
marks.set(record.id, { record, reason });
|
|
630
|
-
};
|
|
631
|
-
// Every live foreground task below a live owner with cancellation intent, which is its nearest one: a walk down
|
|
632
|
-
// from each owner with intent does not enter another, whose own walk covers what is below it. Derived again on
|
|
633
|
-
// every pass, as loading edges can reveal a cancelled owner.
|
|
634
|
-
for (const id of this.#intent) {
|
|
635
|
-
const owner = this.#live.get(id);
|
|
636
|
-
// Only an owner abandoned after a restart, and nothing else, passes its reason on.
|
|
637
|
-
const restart = owner.abortReason === "restart" && !failedOutcome(owner);
|
|
638
|
-
for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) {
|
|
639
|
-
if (live !== undefined && !node.background)
|
|
640
|
-
mark(live, restart ? "restart" : undefined);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
for (const id of checks) {
|
|
644
|
-
const waiter = this.#live.get(id);
|
|
645
|
-
if (waiter?.state.status !== "waiting" || !(await this.#anyFailed(waiter.state.on)))
|
|
646
|
-
continue;
|
|
647
|
-
// Every other live task: the failed one keeps its own outcome.
|
|
648
|
-
for (const member of waiter.state.on) {
|
|
649
|
-
const record = this.#live.get(member);
|
|
650
|
-
if (record !== undefined && !failedOutcome(record))
|
|
651
|
-
mark(record, undefined);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
852
|
+
if (cascade)
|
|
853
|
+
this.#cascadePending = false;
|
|
854
|
+
const marks = await this.#marks();
|
|
654
855
|
for (const { record, reason } of marks.values())
|
|
655
856
|
tx.setTask(withAbortMark(record, reason));
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
857
|
+
const withdrawn = await this.#region(() => queued.filter((id) => this.#belowCancelled({ conversation: id })), this.#reloadScopes);
|
|
858
|
+
for (const id of withdrawn)
|
|
859
|
+
await this.#withdrawInputs(tx, id);
|
|
659
860
|
await this.#finalize(tx);
|
|
660
861
|
}, this.#context);
|
|
661
862
|
}
|
|
@@ -667,63 +868,169 @@ export class TaskScheduler {
|
|
|
667
868
|
if (work)
|
|
668
869
|
this.#reconcilingWork--;
|
|
669
870
|
this.#scheduleWake();
|
|
670
|
-
this
|
|
871
|
+
// Its commit's task changes were settled at their publication; a waiter that asked for this pass checks again, and
|
|
872
|
+
// what its chain loads resolved can make any scope idle.
|
|
873
|
+
if (this.#unknownsChanged) {
|
|
874
|
+
this.#settleIdle(undefined);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
this.#resolveIdleWaiters(undefined);
|
|
878
|
+
// A pass checks expiry too, for hosts that decline the expiry timer.
|
|
879
|
+
this.#expireContexts(this.#now(), this.#contextRetentionMs());
|
|
880
|
+
this.#scheduleExpiry();
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* The abort marks committed records imply (spec §5.4, §5.5): every live foreground task below a live owner with
|
|
884
|
+
* cancellation intent, which is its nearest one (a walk down from each owner with intent does not enter another,
|
|
885
|
+
* whose own walk covers what is below it), and every other live task in the `on` of a `failFast` waiter one of whose
|
|
886
|
+
* tasks failed. A cascade from an abandoned owner passes its `restart` reason on; any other intent marks, or upgrades
|
|
887
|
+
* a `restart` mark, as a request. Collected first, so a request wins over a `restart` mark of the same pass. Derived
|
|
888
|
+
* again on every pass, as loading edges can reveal a cancelled owner. Takes the `failFast` checks once its decision
|
|
889
|
+
* stands.
|
|
890
|
+
*/
|
|
891
|
+
async #marks() {
|
|
892
|
+
// Terminal tasks in a checked `on`, read from Storage: each one's outcome is final.
|
|
893
|
+
const ended = new Map();
|
|
894
|
+
for (;;) {
|
|
895
|
+
const plan = await this.#region(() => this.#planMarks(ended), this.#reloadScopes);
|
|
896
|
+
if ("missing" in plan) {
|
|
897
|
+
for (const id of plan.missing)
|
|
898
|
+
ended.set(id, (await this.#session.lineStorage.task(id, this.#context)) ?? null);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
for (const id of plan.checked)
|
|
902
|
+
this.#failFastChecks.delete(id);
|
|
903
|
+
return plan.marks;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/** `#marks()`' decision region; the `failFast` members it needs from Storage first, when `ended` lacks them. */
|
|
907
|
+
#planMarks(ended) {
|
|
908
|
+
const marks = new Map();
|
|
909
|
+
// A mark rewrites the whole record, so the record it copies is read as a task record, keyed: a commit of the task
|
|
910
|
+
// that is not published yet makes the region wait and run again, rather than stage a mark over a stale checkpoint.
|
|
911
|
+
const mark = (found, reason) => {
|
|
912
|
+
const record = this.#liveRecord(found.id);
|
|
913
|
+
const staged = marks.get(record.id);
|
|
914
|
+
if (staged !== undefined && staged.reason === undefined)
|
|
915
|
+
return;
|
|
916
|
+
if (record.abortRequested && (record.abortReason === undefined || reason !== undefined))
|
|
917
|
+
return;
|
|
918
|
+
marks.set(record.id, { record, reason });
|
|
919
|
+
};
|
|
920
|
+
this.#record(INTENT_KEY);
|
|
921
|
+
for (const id of this.#intent) {
|
|
922
|
+
const owner = this.#liveRecord(id);
|
|
923
|
+
// Only an owner abandoned after a restart, and nothing else, passes its reason on.
|
|
924
|
+
const restart = owner.abortReason === "restart" && !failedOutcome(owner);
|
|
925
|
+
for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) {
|
|
926
|
+
if (live !== undefined && !node.background)
|
|
927
|
+
mark(live, restart ? "restart" : undefined);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const checked = [...this.#failFastChecks];
|
|
931
|
+
const missing = [];
|
|
932
|
+
for (const id of checked) {
|
|
933
|
+
const waiter = this.#liveRecord(id);
|
|
934
|
+
if (waiter?.state.status !== "waiting")
|
|
935
|
+
continue;
|
|
936
|
+
let failed = false;
|
|
937
|
+
for (const member of waiter.state.on) {
|
|
938
|
+
const record = this.#liveRecord(member) ?? ended.get(member);
|
|
939
|
+
if (record === undefined)
|
|
940
|
+
missing.push(member);
|
|
941
|
+
else if (record !== null && failedOutcome(record))
|
|
942
|
+
failed = true;
|
|
943
|
+
}
|
|
944
|
+
if (!failed)
|
|
945
|
+
continue;
|
|
946
|
+
// Every other live task: the failed one keeps its own outcome.
|
|
947
|
+
for (const member of waiter.state.on) {
|
|
948
|
+
const record = this.#liveRecord(member);
|
|
949
|
+
if (record !== undefined && !failedOutcome(record))
|
|
950
|
+
mark(record, undefined);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return missing.length > 0 ? { missing } : { marks, checked };
|
|
671
954
|
}
|
|
672
955
|
/** Whether a reconcile pass would have work: a cascade to derive, held outcomes to check, or `failFast` waits. */
|
|
673
956
|
#reconcileWork() {
|
|
674
957
|
return this.#cascadePending || this.#finalizeChecks.size > 0 || this.#failFastChecks.size > 0;
|
|
675
958
|
}
|
|
676
|
-
/** Whether any of `ids` holds or ended with an outcome other than `completed`. */
|
|
677
|
-
async #anyFailed(ids) {
|
|
678
|
-
for (const id of ids) {
|
|
679
|
-
const record = this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
|
|
680
|
-
if (record !== undefined && failedOutcome(record))
|
|
681
|
-
return true;
|
|
682
|
-
}
|
|
683
|
-
return false;
|
|
684
|
-
}
|
|
685
959
|
/**
|
|
686
960
|
* Write the terminal record of every `completing` task without live ordinary owned work. Finalizing one can free its
|
|
687
961
|
* owner, so this repeats over the commit's candidates until nothing changes. A held scheduler outcome gets its
|
|
688
|
-
* Harness cleanup here.
|
|
962
|
+
* Harness cleanup here. In rounds, one decision region each: the holders the tasks of one round free are checked once,
|
|
963
|
+
* in the next, so a holder of many tasks that end together is walked once, not once per task. The first round takes
|
|
964
|
+
* the `completing` tasks to check once its decision stands.
|
|
689
965
|
*/
|
|
690
966
|
async #finalize(tx) {
|
|
691
|
-
// A worklist: the tasks that started holding, and the holders of work that ended. Finalizing one can free the
|
|
692
|
-
// nearest live task above it, which goes on the list. Tasks finalized here have no live work below them, so walks
|
|
693
|
-
// down skip them.
|
|
694
|
-
// In rounds: the holders the tasks of one round free are checked once, in the next, so a holder of many tasks that
|
|
695
|
-
// end together is walked once, not once per task.
|
|
696
|
-
let round = [...this.#finalizeChecks];
|
|
697
|
-
this.#finalizeChecks.clear();
|
|
698
967
|
const done = new Set();
|
|
968
|
+
// The commit's candidates, kept in step with the terminal records each round stages.
|
|
699
969
|
const overlay = overlayOf(tx);
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
continue;
|
|
708
|
-
const outcome = record.state.outcome;
|
|
709
|
-
const terminal = withState(record, { status: "terminal", outcome });
|
|
970
|
+
let round;
|
|
971
|
+
for (;;) {
|
|
972
|
+
const ids = round;
|
|
973
|
+
const plan = await this.#region(() => this.#planRound(ids ?? [...this.#finalizeChecks], overlay, done), this.#reloadScopes);
|
|
974
|
+
if (ids === undefined)
|
|
975
|
+
this.#finalizeChecks.clear();
|
|
976
|
+
for (const { record, terminal } of plan.finalized) {
|
|
710
977
|
tx.setTask(terminal);
|
|
711
|
-
overlay.tasks.set(id, terminal);
|
|
712
|
-
done.add(id);
|
|
978
|
+
overlay.tasks.set(record.id, terminal);
|
|
979
|
+
done.add(record.id);
|
|
980
|
+
const outcome = record.state.outcome;
|
|
713
981
|
// REMINDER: only the scheduler writes `faulted` and `orphaned` (spec §5.4); their cleanup waits for this commit.
|
|
714
982
|
if (outcome.status === "faulted" || outcome.status === "orphaned") {
|
|
715
983
|
await this.#settleOutcome(tx, record, outcome);
|
|
716
984
|
}
|
|
985
|
+
}
|
|
986
|
+
if (plan.next.length === 0)
|
|
987
|
+
return;
|
|
988
|
+
round = plan.next;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* One finalize round's decision: which of `ids` are `completing` without live ordinary owned work below them, in the
|
|
993
|
+
* commit's candidates with the round's own finalizations applied in order, and the holders above them to check next.
|
|
994
|
+
* Tasks finalized have no live work below them, so walks down skip them. It applies the round's terminal records to
|
|
995
|
+
* `overlay` while it decides and takes them back before it returns, so a run again starts from the same candidates.
|
|
996
|
+
*/
|
|
997
|
+
#planRound(ids, overlay, finalized) {
|
|
998
|
+
const tasks = overlay.tasks;
|
|
999
|
+
// The tasks finalized so far, earlier rounds' and this one's, without copying the former.
|
|
1000
|
+
const round = new Set();
|
|
1001
|
+
const done = { has: (id) => round.has(id) || finalized.has(id) };
|
|
1002
|
+
const ended = [];
|
|
1003
|
+
const next = new Set();
|
|
1004
|
+
try {
|
|
1005
|
+
for (const id of ids) {
|
|
1006
|
+
const staged = tasks.get(id);
|
|
1007
|
+
const record = staged ?? this.#liveRecord(id);
|
|
1008
|
+
if (record?.state.status !== "completing" || done.has(id))
|
|
1009
|
+
continue;
|
|
1010
|
+
if (this.#hasOwnedLive(id, overlay, done))
|
|
1011
|
+
continue;
|
|
1012
|
+
const terminal = withState(record, { status: "terminal", outcome: record.state.outcome });
|
|
1013
|
+
tasks.set(id, terminal);
|
|
1014
|
+
round.add(id);
|
|
1015
|
+
ended.push({ record, terminal, staged: staged !== undefined });
|
|
717
1016
|
const above = this.#nearestLiveAbove(record, overlay);
|
|
718
1017
|
if (above?.state.status === "completing")
|
|
719
1018
|
next.add(above.id);
|
|
720
1019
|
}
|
|
721
|
-
round = [...next];
|
|
722
1020
|
}
|
|
1021
|
+
finally {
|
|
1022
|
+
for (const { record, staged } of ended) {
|
|
1023
|
+
if (staged)
|
|
1024
|
+
tasks.set(record.id, record);
|
|
1025
|
+
else
|
|
1026
|
+
tasks.delete(record.id);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return { finalized: ended, next: [...next] };
|
|
723
1030
|
}
|
|
724
1031
|
/**
|
|
725
1032
|
* Load the owner chains of the tasks in `#unloaded` and, with `queued`, of every conversation with queued inputs,
|
|
726
|
-
* on the Session line; returns the latter. Reads
|
|
1033
|
+
* on the Session line; returns the latter. Reads through the line's Storage, so it may run inside a commit callback.
|
|
727
1034
|
*/
|
|
728
1035
|
async #loadScopes(queued) {
|
|
729
1036
|
for (const id of [...this.#unloaded]) {
|
|
@@ -733,7 +1040,8 @@ export class TaskScheduler {
|
|
|
733
1040
|
}
|
|
734
1041
|
if (!queued)
|
|
735
1042
|
return [];
|
|
736
|
-
|
|
1043
|
+
// A scan, which cannot be keyed: it waits for every unpublished commit.
|
|
1044
|
+
const submissions = await scanAll((cursor) => this.#session.lineStorage.scanSubmissions({ status: "queued" }, SCAN_PAGE_SIZE, cursor, this.#context));
|
|
737
1045
|
const inputs = submissions.filter((submission) => submission.type === "input");
|
|
738
1046
|
const conversations = [...new Set(inputs.map((submission) => submission.conversationId))];
|
|
739
1047
|
for (const id of conversations)
|
|
@@ -743,48 +1051,76 @@ export class TaskScheduler {
|
|
|
743
1051
|
/**
|
|
744
1052
|
* Load the owner edges and task nodes from `start` up to its ownerless root. A known task outside `#unloaded` ends the
|
|
745
1053
|
* walk, as its chain is loaded; the `#unloaded` tasks passed have a loaded chain afterwards. Loading a node can reveal
|
|
746
|
-
* a cancelled owner, so it makes the next reconcile derive cascade marks.
|
|
1054
|
+
* a cancelled owner, so it makes the next reconcile derive cascade marks. The nodes it installs stay until the job's
|
|
1055
|
+
* commit is published (`#pin()`).
|
|
747
1056
|
*/
|
|
748
1057
|
async #loadChain(start, overlay) {
|
|
749
1058
|
const passed = [];
|
|
1059
|
+
let installed = false;
|
|
750
1060
|
let at = start;
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1061
|
+
try {
|
|
1062
|
+
while (at !== undefined) {
|
|
1063
|
+
if ("task" in at) {
|
|
1064
|
+
const id = at.task;
|
|
1065
|
+
let node = this.#live.get(id) ?? this.#settled.get(id);
|
|
1066
|
+
// Created in this commit: the load from its own record covers what is above it.
|
|
1067
|
+
if (node === undefined && overlay?.tasks.has(id) === true)
|
|
1068
|
+
break;
|
|
1069
|
+
if (node !== undefined && !this.#unloaded.has(id))
|
|
1070
|
+
break;
|
|
1071
|
+
if (node === undefined) {
|
|
1072
|
+
// Through the line's Storage: a task whose creation or end is not published yet waits for it.
|
|
1073
|
+
const record = await this.#session.lineStorage.task(id, this.#context);
|
|
1074
|
+
if (record === undefined)
|
|
1075
|
+
return;
|
|
1076
|
+
node = nodeOf(record);
|
|
1077
|
+
if (record.state.status === "terminal") {
|
|
1078
|
+
this.#settle(id, node);
|
|
1079
|
+
this.#pin(id);
|
|
1080
|
+
installed = true;
|
|
1081
|
+
}
|
|
1082
|
+
// A loaded node can reveal a cancelling owner: the next reconcile derives marks again.
|
|
1083
|
+
this.#scheduleReconcile();
|
|
1084
|
+
}
|
|
1085
|
+
passed.push(id);
|
|
1086
|
+
at = parentOf(node);
|
|
769
1087
|
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1088
|
+
else {
|
|
1089
|
+
let edge = this.#edge(at.conversation, overlay);
|
|
1090
|
+
if (edge === undefined) {
|
|
1091
|
+
// Through the line's Storage: a conversation whose creation is not published yet waits for it, so a missing
|
|
1092
|
+
// record is never mistaken for an ownerless one.
|
|
1093
|
+
const record = await this.#session.lineStorage.conversation(at.conversation, this.#context);
|
|
1094
|
+
edge = record?.owner?.taskId ?? null;
|
|
1095
|
+
this.#setEdge(at.conversation, edge);
|
|
1096
|
+
this.#pin(conversationNode(at.conversation));
|
|
1097
|
+
installed = true;
|
|
1098
|
+
// A loaded node can reveal a cancelling owner: the next reconcile derives marks again.
|
|
1099
|
+
this.#scheduleReconcile();
|
|
1100
|
+
}
|
|
1101
|
+
at = edge === null ? undefined : { task: edge };
|
|
781
1102
|
}
|
|
782
|
-
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
finally {
|
|
1106
|
+
// `#idle()` counts a live task whose chain is not loaded as inside every scope it may be in: what this load
|
|
1107
|
+
// resolved, also when it stopped early, can make such a scope idle without a task change below it.
|
|
1108
|
+
if (passed.some((id) => this.#live.has(id)) || (installed && this.#liveUnloaded())) {
|
|
1109
|
+
this.#unknownsChanged = true;
|
|
1110
|
+
// A waiter such a task held is checked again at the pass's end, also when this load was not a reconcile's.
|
|
1111
|
+
this.#scheduleReconcile();
|
|
783
1112
|
}
|
|
784
1113
|
}
|
|
785
1114
|
for (const id of passed)
|
|
786
1115
|
this.#unloaded.delete(id);
|
|
787
1116
|
}
|
|
1117
|
+
/** Whether a live task's chain is not loaded yet: one `#idle()` counts as inside every scope it may be in. */
|
|
1118
|
+
#liveUnloaded() {
|
|
1119
|
+
for (const id of this.#unloaded)
|
|
1120
|
+
if (this.#live.has(id))
|
|
1121
|
+
return true;
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
788
1124
|
#setEdge(conversationId, owner) {
|
|
789
1125
|
this.#edges.set(conversationId, owner);
|
|
790
1126
|
// The conversation now hangs from its owner, or is a root; with nothing below it, it waits for the sweep.
|
|
@@ -831,7 +1167,8 @@ export class TaskScheduler {
|
|
|
831
1167
|
*/
|
|
832
1168
|
#sweep() {
|
|
833
1169
|
for (const node of this.#dropQueue) {
|
|
834
|
-
|
|
1170
|
+
// Queued again once its pins go.
|
|
1171
|
+
if (this.#below.has(node) || this.#pins.has(node))
|
|
835
1172
|
continue;
|
|
836
1173
|
if (typeof node === "string") {
|
|
837
1174
|
this.#edges.delete(conversationOf(node));
|
|
@@ -844,6 +1181,69 @@ export class TaskScheduler {
|
|
|
844
1181
|
}
|
|
845
1182
|
this.#dropQueue.clear();
|
|
846
1183
|
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Run `decide`, a decision region (group commit §6): synchronous, reading scheduler memory and recording the keys it
|
|
1186
|
+
* reads, staging nothing and changing no scheduler state. When a key it read has an unpublished writer, its result,
|
|
1187
|
+
* or its error, is dropped: once that writer is published, `refresh` reloads what the region needs and it runs
|
|
1188
|
+
* again. A waiting job holds the line, so unpublished writes only shrink, and the runs converge. What a region read
|
|
1189
|
+
* that had no unpublished writer gets none for the rest of the job, so its result stands while the job applies it.
|
|
1190
|
+
*/
|
|
1191
|
+
async #region(decide, refresh) {
|
|
1192
|
+
for (;;) {
|
|
1193
|
+
const keys = new Set();
|
|
1194
|
+
this.#reading = keys;
|
|
1195
|
+
let result;
|
|
1196
|
+
try {
|
|
1197
|
+
result = { value: decide() };
|
|
1198
|
+
}
|
|
1199
|
+
catch (error) {
|
|
1200
|
+
result = { error };
|
|
1201
|
+
}
|
|
1202
|
+
finally {
|
|
1203
|
+
this.#reading = undefined;
|
|
1204
|
+
}
|
|
1205
|
+
const gates = [];
|
|
1206
|
+
for (const key of keys) {
|
|
1207
|
+
const gate = this.#session.touch(key);
|
|
1208
|
+
if (gate !== undefined)
|
|
1209
|
+
gates.push(gate);
|
|
1210
|
+
}
|
|
1211
|
+
if (gates.length === 0) {
|
|
1212
|
+
if ("error" in result)
|
|
1213
|
+
throw result.error;
|
|
1214
|
+
return result.value;
|
|
1215
|
+
}
|
|
1216
|
+
await Promise.all(gates);
|
|
1217
|
+
await refresh?.();
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
/** Load the chains a region walks, again after it waited: what was published meanwhile may list new work. */
|
|
1221
|
+
#reloadScopes = () => this.#loadScopes(false);
|
|
1222
|
+
/** In a decision region, note that it reads `key`; anywhere else, nothing. */
|
|
1223
|
+
#record(key) {
|
|
1224
|
+
this.#reading?.add(key);
|
|
1225
|
+
}
|
|
1226
|
+
/** A live task's committed record, read by a decision region. */
|
|
1227
|
+
#liveRecord(id) {
|
|
1228
|
+
this.#record(taskKey(id));
|
|
1229
|
+
return this.#live.get(id);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Keep `node`, which a chain load of the job on the line installed, until that job's commit is published or fails:
|
|
1233
|
+
* work it creates below the node needs it, and the sweep would drop it as unneeded before then.
|
|
1234
|
+
*/
|
|
1235
|
+
#pin(node) {
|
|
1236
|
+
this.#pins.set(node, (this.#pins.get(node) ?? 0) + 1);
|
|
1237
|
+
this.#session.onJobEnd(() => {
|
|
1238
|
+
const count = this.#pins.get(node) - 1;
|
|
1239
|
+
if (count > 0) {
|
|
1240
|
+
this.#pins.set(node, count);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
this.#pins.delete(node);
|
|
1244
|
+
this.#dropIfUnneeded(node);
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
847
1247
|
/**
|
|
848
1248
|
* Bring the scheduler's sets in line with a task's committed record. A task that ended may free the nearest live task
|
|
849
1249
|
* above it: one holding its outcome finalizes, an abort-marked one runs its abort handler; its waiters may run.
|
|
@@ -855,11 +1255,11 @@ export class TaskScheduler {
|
|
|
855
1255
|
invocation.record = record;
|
|
856
1256
|
if (record.state.status === "terminal") {
|
|
857
1257
|
if (previous !== undefined && !this.#closing)
|
|
858
|
-
this.#
|
|
1258
|
+
this.#countActive(-1, record.kind);
|
|
859
1259
|
this.#resumed.delete(id);
|
|
860
1260
|
}
|
|
861
1261
|
else if (previous === undefined && !this.#closing) {
|
|
862
|
-
this.#
|
|
1262
|
+
this.#countActive(1, record.kind);
|
|
863
1263
|
}
|
|
864
1264
|
if (previous?.state.status === "waiting")
|
|
865
1265
|
for (const on of previous.state.on)
|
|
@@ -967,11 +1367,12 @@ export class TaskScheduler {
|
|
|
967
1367
|
* at or below a background task holds nothing above it.
|
|
968
1368
|
*/
|
|
969
1369
|
#nearestLiveAbove(node, overlay) {
|
|
1370
|
+
this.#record(INTENT_KEY);
|
|
970
1371
|
for (let current = node; !current.background;) {
|
|
971
1372
|
const owner = current.owner ?? this.#edge(current.conversationId, overlay);
|
|
972
1373
|
if (owner === undefined || owner === null)
|
|
973
1374
|
return undefined;
|
|
974
|
-
const record = overlay?.tasks.get(owner) ?? this.#
|
|
1375
|
+
const record = overlay?.tasks.get(owner) ?? this.#liveRecord(owner);
|
|
975
1376
|
if (record !== undefined && record.state.status !== "terminal")
|
|
976
1377
|
return record;
|
|
977
1378
|
const above = record ?? this.#settled.get(owner);
|
|
@@ -991,7 +1392,10 @@ export class TaskScheduler {
|
|
|
991
1392
|
}
|
|
992
1393
|
/** Owner task of a conversation, `null` when ownerless, `undefined` while not loaded. */
|
|
993
1394
|
#edge(id, overlay) {
|
|
994
|
-
|
|
1395
|
+
if (overlay?.edges.has(id) === true)
|
|
1396
|
+
return overlay.edges.get(id);
|
|
1397
|
+
this.#record(conversationKey(id));
|
|
1398
|
+
return this.#edges.get(id);
|
|
995
1399
|
}
|
|
996
1400
|
#node(id, overlay) {
|
|
997
1401
|
return overlay?.tasks.get(id) ?? this.#live.get(id) ?? this.#settled.get(id);
|
|
@@ -1030,6 +1434,7 @@ export class TaskScheduler {
|
|
|
1030
1434
|
const links = overlay === undefined ? undefined : this.#links(overlay);
|
|
1031
1435
|
const pending = [start];
|
|
1032
1436
|
for (let at = pending.pop(); at !== undefined; at = pending.pop()) {
|
|
1437
|
+
this.#record(ownedKey(at));
|
|
1033
1438
|
for (const children of [this.#below.get(at), links?.get(at)]) {
|
|
1034
1439
|
for (const child of children ?? []) {
|
|
1035
1440
|
if (typeof child === "string") {
|
|
@@ -1117,6 +1522,7 @@ export class TaskScheduler {
|
|
|
1117
1522
|
* edge is not loaded.
|
|
1118
1523
|
*/
|
|
1119
1524
|
#inScope(start, scope, crossBackground = false) {
|
|
1525
|
+
this.#record(INTENT_KEY);
|
|
1120
1526
|
for (const step of this.#above(start)) {
|
|
1121
1527
|
if ("unknown" in step)
|
|
1122
1528
|
return undefined;
|
|
@@ -1139,6 +1545,7 @@ export class TaskScheduler {
|
|
|
1139
1545
|
}
|
|
1140
1546
|
/** The nearest live owner above `start` whose cancellation intent reaches it, if any; see `#belowCancelled`. */
|
|
1141
1547
|
#cancellingOwner(start) {
|
|
1548
|
+
this.#record(INTENT_KEY);
|
|
1142
1549
|
// Nothing cancels, the common case: no walk.
|
|
1143
1550
|
if (this.#intent.size === 0)
|
|
1144
1551
|
return undefined;
|
|
@@ -1155,6 +1562,18 @@ export class TaskScheduler {
|
|
|
1155
1562
|
}
|
|
1156
1563
|
return undefined;
|
|
1157
1564
|
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Count a task in or out of `pi.durable.tasks.active`. Commit observers count at a commit's publication, which must not
|
|
1567
|
+
* throw, so a throwing adapter is reported.
|
|
1568
|
+
*/
|
|
1569
|
+
#countActive(delta, kind) {
|
|
1570
|
+
try {
|
|
1571
|
+
this.#activeMetric.add(delta, { task: kind });
|
|
1572
|
+
}
|
|
1573
|
+
catch (error) {
|
|
1574
|
+
this.#report(error, TELEMETRY);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1158
1577
|
/** Fail the Session, which reports it, for a throw in one of the scheduler's own commits; ignored once closing. */
|
|
1159
1578
|
#failSession(error, context) {
|
|
1160
1579
|
if (!this.#closing)
|
|
@@ -1163,7 +1582,7 @@ export class TaskScheduler {
|
|
|
1163
1582
|
/** Close listener: runs synchronously once admission is sealed, before `join()`, or when the Session fails. */
|
|
1164
1583
|
#seal() {
|
|
1165
1584
|
for (const record of this.#live.values())
|
|
1166
|
-
this.#
|
|
1585
|
+
this.#countActive(-1, record.kind);
|
|
1167
1586
|
this.#resumed.clear();
|
|
1168
1587
|
this.#closing = true;
|
|
1169
1588
|
try {
|
|
@@ -1176,14 +1595,18 @@ export class TaskScheduler {
|
|
|
1176
1595
|
this.#taskWaiters.rejectAll(error);
|
|
1177
1596
|
this.#idleWaiters.rejectAll(error);
|
|
1178
1597
|
this.#contexts.clear();
|
|
1598
|
+
this.#idleOrder.clear();
|
|
1179
1599
|
this.#expiry?.disarm();
|
|
1180
1600
|
this.#expiry = undefined;
|
|
1181
1601
|
this.#timer?.disarm();
|
|
1182
1602
|
this.#timer = undefined;
|
|
1183
1603
|
}
|
|
1184
|
-
/**
|
|
1604
|
+
/**
|
|
1605
|
+
* Recompute the wake in a microtask the line never waits for, with no order relative to host commit listeners (one
|
|
1606
|
+
* already queued serves later changes too): arm the built-in timer, or tell `onWake` of a change.
|
|
1607
|
+
*/
|
|
1185
1608
|
#scheduleWake() {
|
|
1186
|
-
if (this.#wakeScheduled || this.#closing)
|
|
1609
|
+
if (this.#wakeScheduled || this.#closing || this.#readOnly)
|
|
1187
1610
|
return;
|
|
1188
1611
|
this.#wakeScheduled = true;
|
|
1189
1612
|
queueMicrotask(() => {
|
|
@@ -1235,10 +1658,6 @@ export class TaskScheduler {
|
|
|
1235
1658
|
this.#runnable.add(id);
|
|
1236
1659
|
}
|
|
1237
1660
|
}
|
|
1238
|
-
/** A task cut off `restartLimit` times in a row; an abort mark still runs its abort handler. */
|
|
1239
|
-
#parked(record) {
|
|
1240
|
-
return !record.abortRequested && record.restarts !== undefined && record.restarts >= this.#restartLimit();
|
|
1241
|
-
}
|
|
1242
1661
|
/**
|
|
1243
1662
|
* The report info of a task's failure, with the extension that installed its definition: the code that failed is
|
|
1244
1663
|
* that extension's. Built-in tasks have none.
|
|
@@ -1259,22 +1678,6 @@ export class TaskScheduler {
|
|
|
1259
1678
|
}
|
|
1260
1679
|
return { task: reportTask(task), ...optionalExtension(extension) };
|
|
1261
1680
|
}
|
|
1262
|
-
/** Make parked tasks that a raised `settings.restartLimit` no longer parks candidates again, and tell the graph. */
|
|
1263
|
-
#unparkRaised() {
|
|
1264
|
-
let unparked = false;
|
|
1265
|
-
for (const id of this.#parkedTasks) {
|
|
1266
|
-
const record = this.#live.get(id);
|
|
1267
|
-
if (record !== undefined && this.#parked(record))
|
|
1268
|
-
continue;
|
|
1269
|
-
this.#parkedTasks.delete(id);
|
|
1270
|
-
if (record === undefined)
|
|
1271
|
-
continue;
|
|
1272
|
-
this.#runnable.add(id);
|
|
1273
|
-
unparked = true;
|
|
1274
|
-
}
|
|
1275
|
-
if (unparked)
|
|
1276
|
-
queueMicrotask(() => this.#notifyBlocked());
|
|
1277
|
-
}
|
|
1278
1681
|
/** `settings.restartLimit`, or the default when the host's settings throw. */
|
|
1279
1682
|
#restartLimit() {
|
|
1280
1683
|
try {
|
|
@@ -1291,7 +1694,7 @@ export class TaskScheduler {
|
|
|
1291
1694
|
listener();
|
|
1292
1695
|
}
|
|
1293
1696
|
catch (error) {
|
|
1294
|
-
this.#report(error, {
|
|
1697
|
+
this.#report(error, { kind: "listener_error" });
|
|
1295
1698
|
}
|
|
1296
1699
|
}
|
|
1297
1700
|
}
|
|
@@ -1337,62 +1740,45 @@ export class TaskScheduler {
|
|
|
1337
1740
|
const faulted = [];
|
|
1338
1741
|
let abandoned = [];
|
|
1339
1742
|
try {
|
|
1340
|
-
await this.#session.
|
|
1743
|
+
await this.#session.commitInternal(async (tx) => {
|
|
1341
1744
|
if (!this.#enabled || this.#closing)
|
|
1342
1745
|
return;
|
|
1343
|
-
for (const id of this.#abandoned) {
|
|
1344
|
-
const record = this.#live.get(id);
|
|
1345
|
-
if (record !== undefined && !record.abortRequested)
|
|
1346
|
-
tx.setTask(withAbortMark(record, "restart"));
|
|
1347
|
-
}
|
|
1348
1746
|
if (this.#abandoned.size > 0) {
|
|
1349
|
-
|
|
1747
|
+
const ids = [...this.#abandoned];
|
|
1748
|
+
const unmarked = await this.#region(() => ids
|
|
1749
|
+
.map((id) => this.#liveRecord(id))
|
|
1750
|
+
.filter((record) => record !== undefined && !record.abortRequested));
|
|
1751
|
+
for (const record of unmarked)
|
|
1752
|
+
tx.setTask(withAbortMark(record, "restart"));
|
|
1753
|
+
abandoned = ids;
|
|
1350
1754
|
return;
|
|
1351
1755
|
}
|
|
1352
1756
|
await this.#loadScopes(false);
|
|
1353
1757
|
// Every pass picks up sleeps whose time has come: a pass started by any change may be the first to run after
|
|
1354
1758
|
// a deadline, and the wake it schedules no longer keeps a timer for a due sleep.
|
|
1355
1759
|
this.#wakeDue();
|
|
1356
|
-
this.#
|
|
1760
|
+
const plan = await this.#region(() => this.#planReservation(), this.#reloadScopes);
|
|
1761
|
+
for (const id of plan.dropped)
|
|
1762
|
+
this.#runnable.delete(id);
|
|
1763
|
+
// Work below an owner with cancellation intent waits for its cascade mark instead of running a phase; schedule
|
|
1764
|
+
// the cascade, which may not have run yet.
|
|
1765
|
+
if (plan.cascade) {
|
|
1766
|
+
this.#cascadePending = true;
|
|
1767
|
+
this.#scheduleReconcile();
|
|
1768
|
+
}
|
|
1357
1769
|
// Taken once per pass, and only when some task is a candidate.
|
|
1358
1770
|
let snapshot;
|
|
1359
|
-
for (const
|
|
1360
|
-
const
|
|
1361
|
-
if (record === undefined || record.state.status === "completing" || this.#invocations.has(id)) {
|
|
1362
|
-
this.#runnable.delete(id);
|
|
1363
|
-
continue;
|
|
1364
|
-
}
|
|
1365
|
-
// It returns once what it waits for ends, its record changes, or the work below it ends.
|
|
1366
|
-
if (this.#waitingOn(record, true).length > 0) {
|
|
1367
|
-
this.#runnable.delete(id);
|
|
1368
|
-
continue;
|
|
1369
|
-
}
|
|
1370
|
-
// It returns at the first pass after its time comes, which the timer, `resume()`, or any change starts.
|
|
1371
|
-
if (this.#notDue(record)) {
|
|
1372
|
-
this.#runnable.delete(id);
|
|
1373
|
-
continue;
|
|
1374
|
-
}
|
|
1375
|
-
// It returns when `retry()` clears its restart count, or at a pass after `settings.restartLimit` rose.
|
|
1376
|
-
if (this.#parked(record)) {
|
|
1377
|
-
this.#runnable.delete(id);
|
|
1378
|
-
// Newly parked without a commit, as after a lowered limit: the graph must hear it.
|
|
1379
|
-
if (!this.#parkedTasks.has(id))
|
|
1380
|
-
queueMicrotask(() => this.#notifyBlocked());
|
|
1381
|
-
this.#parkedTasks.add(id);
|
|
1382
|
-
continue;
|
|
1383
|
-
}
|
|
1384
|
-
const runnable = record;
|
|
1771
|
+
for (const record of plan.ready) {
|
|
1772
|
+
const id = record.id;
|
|
1385
1773
|
const mode = record.abortRequested ? "abort" : "run";
|
|
1386
|
-
// Work
|
|
1387
|
-
// the
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
if (mode === "abort" && (record.restarts ?? 0) >= this.#restartLimit()) {
|
|
1395
|
-
const message = `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times`;
|
|
1774
|
+
// Work cut off `restartLimit` times in a row would run forever: an abort handler, or a phase of a kind that
|
|
1775
|
+
// does not end itself at the limit, ends the task `faulted` instead.
|
|
1776
|
+
// The settings are read only for a task that was cut off at all.
|
|
1777
|
+
const cutOff = record.restarts !== undefined && record.restarts >= this.#restartLimit();
|
|
1778
|
+
if (cutOff && (mode === "abort" || !this.#limitsRestarts(record.kind))) {
|
|
1779
|
+
const message = mode === "abort"
|
|
1780
|
+
? `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times`
|
|
1781
|
+
: `Task ${id} (${record.kind}) was cut off ${record.restarts} times without progress`;
|
|
1396
1782
|
await this.#terminate(tx, record, {
|
|
1397
1783
|
status: "faulted",
|
|
1398
1784
|
error: { message, detail: { reason: "restart_limit" } },
|
|
@@ -1401,7 +1787,7 @@ export class TaskScheduler {
|
|
|
1401
1787
|
continue;
|
|
1402
1788
|
}
|
|
1403
1789
|
snapshot ??= this.#registry.snapshot();
|
|
1404
|
-
const resolution = this.#resolve(
|
|
1790
|
+
const resolution = this.#resolve(record, snapshot);
|
|
1405
1791
|
if (resolution.kind === "blocked") {
|
|
1406
1792
|
// An abandoned task waits for its definition, so its abort handler can clean up.
|
|
1407
1793
|
if (mode === "abort" && record.abortReason === undefined) {
|
|
@@ -1409,7 +1795,7 @@ export class TaskScheduler {
|
|
|
1409
1795
|
}
|
|
1410
1796
|
continue;
|
|
1411
1797
|
}
|
|
1412
|
-
if (resolution.record !==
|
|
1798
|
+
if (resolution.record !== record || record.state.status !== "running") {
|
|
1413
1799
|
tx.setTask(withState(resolution.record, {
|
|
1414
1800
|
status: "running",
|
|
1415
1801
|
checkpoint: resolution.record.state.checkpoint,
|
|
@@ -1418,7 +1804,7 @@ export class TaskScheduler {
|
|
|
1418
1804
|
// Registered on the line, so marks and later reservations see it and close joins it; `#end` makes it a
|
|
1419
1805
|
// candidate again.
|
|
1420
1806
|
this.#runnable.delete(id);
|
|
1421
|
-
const invocation = this.#createInvocation(
|
|
1807
|
+
const invocation = this.#createInvocation(record, mode, installerOf(snapshot, resolution.task));
|
|
1422
1808
|
reservations.push({ invocation, task: resolution.task, snapshot });
|
|
1423
1809
|
}
|
|
1424
1810
|
}, this.#context);
|
|
@@ -1433,7 +1819,7 @@ export class TaskScheduler {
|
|
|
1433
1819
|
}
|
|
1434
1820
|
for (const { record, message } of faulted) {
|
|
1435
1821
|
this.#report(new Error(message), {
|
|
1436
|
-
|
|
1822
|
+
kind: "task_faulted",
|
|
1437
1823
|
...this.#aboutTask(recordTask(record)),
|
|
1438
1824
|
reason: "restart_limit",
|
|
1439
1825
|
});
|
|
@@ -1445,6 +1831,39 @@ export class TaskScheduler {
|
|
|
1445
1831
|
this.#dirty = true;
|
|
1446
1832
|
return reservations;
|
|
1447
1833
|
}
|
|
1834
|
+
/**
|
|
1835
|
+
* A reservation pass's decision region: the candidates it drops (gone, holding, waiting, asleep, or with an
|
|
1836
|
+
* invocation), whether one waits for a cascade, and the runnable records to resolve. Iterating the candidates is not
|
|
1837
|
+
* keyed: a pass that misses a task a pending commit makes runnable only delays it, as does skipping one with an
|
|
1838
|
+
* invocation without reading its record, or a waiter's check whether what it waits on is still live.
|
|
1839
|
+
*/
|
|
1840
|
+
#planReservation() {
|
|
1841
|
+
const dropped = [];
|
|
1842
|
+
const ready = [];
|
|
1843
|
+
let cascade = false;
|
|
1844
|
+
for (const id of this.#runnable) {
|
|
1845
|
+
if (this.#invocations.has(id)) {
|
|
1846
|
+
dropped.push(id);
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
const record = this.#liveRecord(id);
|
|
1850
|
+
// It returns once what it waits for ends, its record changes, or the work below it ends; a sleep at the first pass
|
|
1851
|
+
// after its time comes, which the timer, `resume()`, or any change starts.
|
|
1852
|
+
if (record === undefined ||
|
|
1853
|
+
record.state.status === "completing" ||
|
|
1854
|
+
this.#waitingOn(record, true).length > 0 ||
|
|
1855
|
+
this.#notDue(record)) {
|
|
1856
|
+
dropped.push(id);
|
|
1857
|
+
continue;
|
|
1858
|
+
}
|
|
1859
|
+
if (!record.abortRequested && !record.background && this.#belowCancelled(parentOf(record))) {
|
|
1860
|
+
cascade = true;
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
ready.push(record);
|
|
1864
|
+
}
|
|
1865
|
+
return { dropped, cascade, ready };
|
|
1866
|
+
}
|
|
1448
1867
|
/**
|
|
1449
1868
|
* Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since
|
|
1450
1869
|
* abort runs bottom-up, otherwise the live part of the `on` of a wait.
|
|
@@ -1481,7 +1900,7 @@ export class TaskScheduler {
|
|
|
1481
1900
|
queueMicrotask(() => this.#notifyBlocked());
|
|
1482
1901
|
this.#scheduleWake();
|
|
1483
1902
|
const extension = installerOf(snapshot, fit.task);
|
|
1484
|
-
this.#report(error, {
|
|
1903
|
+
this.#report(error, { kind: "migration_failed", ...this.#aboutTask({ ...recordTask(record), extension }) });
|
|
1485
1904
|
return { kind: "blocked", reason: "migration_failed" };
|
|
1486
1905
|
}
|
|
1487
1906
|
}
|
|
@@ -1511,6 +1930,36 @@ export class TaskScheduler {
|
|
|
1511
1930
|
roots: this.#roots.size,
|
|
1512
1931
|
unloaded: this.#unloaded.size,
|
|
1513
1932
|
dropQueue: this.#dropQueue.size,
|
|
1933
|
+
pins: this.#pins.size,
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* The kept contexts' idle state, for tests (a diagnostic, computed on demand): how many are kept and how many are
|
|
1938
|
+
* idle, how many disagree with a full recompute (an idle period without `#idle()`, or `#idle()` without one; an
|
|
1939
|
+
* expiry order that differs from the idle periods, or is not sorted), and how many idle checks covered every scope.
|
|
1940
|
+
*/
|
|
1941
|
+
get idleState() {
|
|
1942
|
+
let mismatches = 0;
|
|
1943
|
+
let idle = 0;
|
|
1944
|
+
for (const [conversationId, kept] of this.#contexts) {
|
|
1945
|
+
if (kept.idleSince !== undefined)
|
|
1946
|
+
idle++;
|
|
1947
|
+
if ((kept.idleSince !== undefined) !== this.#idle(conversationId))
|
|
1948
|
+
mismatches++;
|
|
1949
|
+
if (this.#idleOrder.get(conversationId) !== kept.idleSince)
|
|
1950
|
+
mismatches++;
|
|
1951
|
+
}
|
|
1952
|
+
let previous = Number.NEGATIVE_INFINITY;
|
|
1953
|
+
for (const [conversationId, idleSince] of this.#idleOrder) {
|
|
1954
|
+
if (idleSince < previous || this.#contexts.get(conversationId)?.idleSince !== idleSince)
|
|
1955
|
+
mismatches++;
|
|
1956
|
+
previous = idleSince;
|
|
1957
|
+
}
|
|
1958
|
+
return {
|
|
1959
|
+
contexts: this.#contexts.size,
|
|
1960
|
+
idleContexts: idle,
|
|
1961
|
+
idleMismatches: mismatches,
|
|
1962
|
+
fullIdleChecks: this.#fullIdleChecks,
|
|
1514
1963
|
};
|
|
1515
1964
|
}
|
|
1516
1965
|
/**
|
|
@@ -1519,6 +1968,7 @@ export class TaskScheduler {
|
|
|
1519
1968
|
* cannot exist, shows as failed.
|
|
1520
1969
|
*/
|
|
1521
1970
|
async inspect(snapshot) {
|
|
1971
|
+
await this.#published();
|
|
1522
1972
|
await this.#loadScopes(false);
|
|
1523
1973
|
const tasks = [];
|
|
1524
1974
|
for (const record of this.#live.values())
|
|
@@ -1536,15 +1986,13 @@ export class TaskScheduler {
|
|
|
1536
1986
|
return { kind: "waiting", on };
|
|
1537
1987
|
if (this.#notDue(record))
|
|
1538
1988
|
return { kind: "sleeping", until: record.state.until };
|
|
1539
|
-
if (this.#parked(record))
|
|
1540
|
-
return { kind: "blocked", reason: "restart_limit" };
|
|
1541
1989
|
const fit = this.#fit(record, snapshot.task(record.kind));
|
|
1542
1990
|
if ("reason" in fit)
|
|
1543
1991
|
return { kind: "blocked", ...fit };
|
|
1544
1992
|
if (fit.migrates && fit.task.definition.migrate === undefined) {
|
|
1545
1993
|
return { kind: "blocked", reason: "migration_failed", error: missingMigration(record, erased(fit.task)) };
|
|
1546
1994
|
}
|
|
1547
|
-
return { kind: "
|
|
1995
|
+
return { kind: "pending", migrates: fit.migrates };
|
|
1548
1996
|
}
|
|
1549
1997
|
#createInvocation(record, mode, extension) {
|
|
1550
1998
|
const controller = new AbortController();
|
|
@@ -1578,7 +2026,7 @@ export class TaskScheduler {
|
|
|
1578
2026
|
await this.#runAbort(reservation);
|
|
1579
2027
|
}
|
|
1580
2028
|
catch (error) {
|
|
1581
|
-
this.#report(error, {
|
|
2029
|
+
this.#report(error, { kind: "task_error", ...this.#aboutTask(invocation) }, invocation.context);
|
|
1582
2030
|
}
|
|
1583
2031
|
finally {
|
|
1584
2032
|
this.#end(invocation);
|
|
@@ -1596,7 +2044,8 @@ export class TaskScheduler {
|
|
|
1596
2044
|
const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined };
|
|
1597
2045
|
const phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined };
|
|
1598
2046
|
const runtime = this.#runtime(invocation, phase);
|
|
1599
|
-
|
|
2047
|
+
const progress = (tx, current) => this.#progress(tx, current, state, invocation.extension, invocation.context);
|
|
2048
|
+
let current = await this.#step(invocation, (task) => this.#rules(task, undefined), progress);
|
|
1600
2049
|
while (current !== undefined && !this.#closing) {
|
|
1601
2050
|
const task = current;
|
|
1602
2051
|
current = await this.#phase(invocation, task, async () => {
|
|
@@ -1611,7 +2060,7 @@ export class TaskScheduler {
|
|
|
1611
2060
|
catch (error) {
|
|
1612
2061
|
previous = { checkpoint, failure: { error } };
|
|
1613
2062
|
}
|
|
1614
|
-
return this.#step(invocation, (
|
|
2063
|
+
return this.#step(invocation, (next) => this.#rules(next, previous), progress);
|
|
1615
2064
|
});
|
|
1616
2065
|
}
|
|
1617
2066
|
}
|
|
@@ -1662,7 +2111,7 @@ export class TaskScheduler {
|
|
|
1662
2111
|
const message = terminal.status === "orphaned" ? terminal.reason : terminal.error.message;
|
|
1663
2112
|
span.setStatus({
|
|
1664
2113
|
status: "error",
|
|
1665
|
-
error: { name: `task_${terminal.status}`, message: message
|
|
2114
|
+
error: { name: `task_${terminal.status}`, message: ownedSlice(message, 0, 500) },
|
|
1666
2115
|
});
|
|
1667
2116
|
}
|
|
1668
2117
|
else if (outcome === "aborted") {
|
|
@@ -1673,45 +2122,50 @@ export class TaskScheduler {
|
|
|
1673
2122
|
});
|
|
1674
2123
|
}
|
|
1675
2124
|
/**
|
|
1676
|
-
* Precedence rules for a run invocation,
|
|
1677
|
-
* (closing) are applied by `#step`.
|
|
2125
|
+
* Precedence rules for a run invocation, a decision region of its step. Rules 1 (terminal, `completing`, `waiting`, or
|
|
2126
|
+
* `sleeping`) and 2 (closing) are applied by `#step`. Progress (rule 5) is decided by `#progress()` once the decision
|
|
2127
|
+
* stands, as it takes a registry snapshot.
|
|
1678
2128
|
*/
|
|
1679
|
-
#
|
|
2129
|
+
#rules(current, previous) {
|
|
1680
2130
|
// 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone.
|
|
1681
2131
|
if (current.abortRequested)
|
|
1682
|
-
return
|
|
2132
|
+
return END;
|
|
1683
2133
|
// An owner's cancellation intent ends it too, before its cascade marks it; reservation then holds it back.
|
|
1684
|
-
if (!current.background && this.#belowCancelled(parentOf(current)))
|
|
1685
|
-
|
|
1686
|
-
this.#scheduleReconcile();
|
|
1687
|
-
return false;
|
|
1688
|
-
}
|
|
2134
|
+
if (!current.background && this.#belowCancelled(parentOf(current)))
|
|
2135
|
+
return CASCADE;
|
|
1689
2136
|
if (previous === undefined)
|
|
1690
|
-
return
|
|
2137
|
+
return CONTINUE;
|
|
1691
2138
|
// 4. uncaught error.
|
|
1692
2139
|
if (previous.failure !== undefined)
|
|
1693
|
-
return { fault: previous.failure.error };
|
|
2140
|
+
return { kind: "fault", error: previous.failure.error };
|
|
1694
2141
|
// 6. no durable progress.
|
|
1695
2142
|
if (jsonEqual(current.state.checkpoint, previous.checkpoint)) {
|
|
1696
2143
|
const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`;
|
|
1697
|
-
return { fault: new Error(message) };
|
|
2144
|
+
return { kind: "fault", error: new Error(message) };
|
|
1698
2145
|
}
|
|
1699
|
-
|
|
2146
|
+
return PROGRESS;
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* Rule 5, progress: refresh the snapshot; hand over to a replacement definition that can take the task, writing it
|
|
2150
|
+
* back as `pending`, or keep running under the old one, reported once per resolved definition. Returns whether the
|
|
2151
|
+
* invocation continues.
|
|
2152
|
+
*/
|
|
2153
|
+
#progress(tx, current, state, extension, context) {
|
|
1700
2154
|
state.snapshot = this.#registry.snapshot();
|
|
1701
2155
|
const next = state.snapshot.task(current.kind);
|
|
1702
|
-
if (next
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
}
|
|
2156
|
+
if (next === state.task)
|
|
2157
|
+
return true;
|
|
2158
|
+
if (next !== undefined && canReserve(next, current)) {
|
|
2159
|
+
tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
|
|
2160
|
+
return false;
|
|
2161
|
+
}
|
|
2162
|
+
if (state.reported === undefined || state.reported.task !== next) {
|
|
2163
|
+
state.reported = { task: next };
|
|
2164
|
+
this.#report(new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`), {
|
|
2165
|
+
kind: "task_outdated",
|
|
2166
|
+
...this.#aboutTask({ ...recordTask(current), extension }),
|
|
2167
|
+
reason: outdatedReason(next, current),
|
|
2168
|
+
}, context);
|
|
1715
2169
|
}
|
|
1716
2170
|
return true;
|
|
1717
2171
|
}
|
|
@@ -1736,28 +2190,42 @@ export class TaskScheduler {
|
|
|
1736
2190
|
failure = { error };
|
|
1737
2191
|
}
|
|
1738
2192
|
const message = `Abort handler of task ${invocation.taskId} returned without a terminal outcome`;
|
|
1739
|
-
await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
|
|
2193
|
+
await this.#step(invocation, () => ({ kind: "fault", error: failure?.error ?? new Error(message) }));
|
|
1740
2194
|
});
|
|
1741
2195
|
}
|
|
1742
2196
|
/**
|
|
1743
|
-
* One
|
|
1744
|
-
* `waiting`, or `sleeping`) or a closing Harness (rule
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
2197
|
+
* One decision on the Session line, before a phase: a decision region (`#region()`) that reads the task's record. A
|
|
2198
|
+
* task that is no longer running (rule 1: terminal, `completing`, `waiting`, or `sleeping`) or a closing Harness (rule
|
|
2199
|
+
* 2) ends the invocation without a write; otherwise `plan` decides, and a decision that stands is applied: the
|
|
2200
|
+
* invocation continues, with `progress` for rule 5, or ends, with a fault written. Ending happens inside the callback,
|
|
2201
|
+
* before a fault's Harness cleanup. A rejected step, such as admission after close, also ends the invocation. Its
|
|
2202
|
+
* outcome goes to the invocation only, so it is a scheduler commit (`commitInternal()`).
|
|
1747
2203
|
*/
|
|
1748
|
-
async #step(invocation,
|
|
2204
|
+
async #step(invocation, plan, progress) {
|
|
1749
2205
|
let fault;
|
|
1750
2206
|
try {
|
|
1751
|
-
const continued = await this.#session.
|
|
1752
|
-
const
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
2207
|
+
const continued = await this.#session.commitInternal(async (tx) => {
|
|
2208
|
+
const decided = await this.#region(() => {
|
|
2209
|
+
const found = this.#liveRecord(invocation.taskId);
|
|
2210
|
+
if (found?.state.status !== "running" || this.#closing)
|
|
2211
|
+
return { plan: END };
|
|
2212
|
+
const current = found;
|
|
2213
|
+
return { current, plan: plan(current) };
|
|
2214
|
+
});
|
|
2215
|
+
const { current } = decided;
|
|
2216
|
+
const step = decided.plan;
|
|
2217
|
+
if (step.kind === "continue")
|
|
2218
|
+
return current;
|
|
2219
|
+
if (step.kind === "progress" && progress(tx, current))
|
|
1756
2220
|
return current;
|
|
1757
2221
|
this.#end(invocation);
|
|
1758
|
-
if (
|
|
1759
|
-
|
|
1760
|
-
|
|
2222
|
+
if (step.kind === "cascade") {
|
|
2223
|
+
this.#cascadePending = true;
|
|
2224
|
+
this.#scheduleReconcile();
|
|
2225
|
+
}
|
|
2226
|
+
if (step.kind === "fault") {
|
|
2227
|
+
fault = { error: step.error };
|
|
2228
|
+
const message = errorMessage(step.error);
|
|
1761
2229
|
invocation.fault = { status: "faulted", error: { message } };
|
|
1762
2230
|
await this.#terminate(tx, current, invocation.fault);
|
|
1763
2231
|
}
|
|
@@ -1765,7 +2233,7 @@ export class TaskScheduler {
|
|
|
1765
2233
|
}, withoutAbortSignal(invocation.context));
|
|
1766
2234
|
// Reported once the fault is durable: a host may count it, for example toward a breaker.
|
|
1767
2235
|
if (fault !== undefined)
|
|
1768
|
-
this.#report(fault.error, {
|
|
2236
|
+
this.#report(fault.error, { kind: "task_faulted", ...this.#aboutTask(invocation), reason: "threw" }, invocation.context);
|
|
1769
2237
|
return continued;
|
|
1770
2238
|
}
|
|
1771
2239
|
catch (error) {
|
|
@@ -1782,7 +2250,7 @@ export class TaskScheduler {
|
|
|
1782
2250
|
*/
|
|
1783
2251
|
async #terminate(tx, record, outcome) {
|
|
1784
2252
|
await this.#loadScopes(false);
|
|
1785
|
-
if (this.#hasOwnedLive(record.id, overlayOf(tx))) {
|
|
2253
|
+
if (await this.#region(() => this.#hasOwnedLive(record.id, overlayOf(tx)), this.#reloadScopes)) {
|
|
1786
2254
|
tx.setTask(withState(record, { status: "completing", outcome }));
|
|
1787
2255
|
return;
|
|
1788
2256
|
}
|
|
@@ -1804,7 +2272,7 @@ export class TaskScheduler {
|
|
|
1804
2272
|
await this.#loadScopes(false);
|
|
1805
2273
|
for (const record of overlay.tasks.values())
|
|
1806
2274
|
await this.#loadChain(parentOf(record), overlay);
|
|
1807
|
-
if (this.#hasOwnedLive(current.id,
|
|
2275
|
+
if (await this.#region(() => this.#hasOwnedLive(current.id, overlayOf(tx)), this.#reloadScopes)) {
|
|
1808
2276
|
tx.setTask(withState(progressed(current), { status: "completing", outcome: next.outcome }));
|
|
1809
2277
|
return;
|
|
1810
2278
|
}
|
|
@@ -1825,37 +2293,64 @@ export class TaskScheduler {
|
|
|
1825
2293
|
async #validateWait(tx, invocation, current, on, policy) {
|
|
1826
2294
|
if (invocation.mode === "abort")
|
|
1827
2295
|
throw new Error(`Abort handler of task ${current.id} cannot wait`);
|
|
1828
|
-
|
|
2296
|
+
// Owned work is found by walks down, which need the chains of every live task loaded.
|
|
2297
|
+
await this.#loadScopes(false);
|
|
2298
|
+
// Tasks in `on` that are not live, read from Storage: terminal, or missing.
|
|
2299
|
+
const ended = new Map();
|
|
2300
|
+
for (;;) {
|
|
2301
|
+
const checked = await this.#region(() => this.#checkWait(current, on, policy, overlayOf(tx), ended), this.#reloadScopes);
|
|
2302
|
+
if (checked === undefined)
|
|
2303
|
+
return;
|
|
2304
|
+
if ("missing" in checked) {
|
|
2305
|
+
for (const id of checked.missing)
|
|
2306
|
+
ended.set(id, (await this.#session.lineStorage.task(id, this.#context)) ?? null);
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
throw checked.error;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* `#validateWait()`'s decision region: why the wait is refused, `undefined` when it may be stored, or the tasks in
|
|
2314
|
+
* `on` it needs from Storage first, when `ended` lacks them.
|
|
2315
|
+
*/
|
|
2316
|
+
#checkWait(current, on, policy, overlay, ended) {
|
|
1829
2317
|
const targets = [];
|
|
2318
|
+
const missing = [];
|
|
1830
2319
|
for (const id of on) {
|
|
1831
|
-
const member = overlay.tasks.get(id) ?? this.#
|
|
1832
|
-
if (member === undefined)
|
|
1833
|
-
|
|
2320
|
+
const member = overlay.tasks.get(id) ?? this.#liveRecord(id) ?? ended.get(id);
|
|
2321
|
+
if (member === undefined) {
|
|
2322
|
+
missing.push(id);
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
if (member === null)
|
|
2326
|
+
return { error: new Error(`Task ${id} does not exist`) };
|
|
1834
2327
|
if (policy === "failFast" && member.owner !== current.id) {
|
|
1835
|
-
|
|
2328
|
+
return { error: new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`) };
|
|
1836
2329
|
}
|
|
1837
2330
|
// A live child already holds the waiter (task-owned tasks are never background): waiting on it adds no edge. A
|
|
1838
2331
|
// terminal task has no edges.
|
|
1839
2332
|
if (member.state.status !== "terminal" && member.owner !== current.id)
|
|
1840
2333
|
targets.push(id);
|
|
1841
2334
|
}
|
|
2335
|
+
if (missing.length > 0)
|
|
2336
|
+
return { missing };
|
|
1842
2337
|
if (targets.length === 0)
|
|
1843
|
-
return;
|
|
1844
|
-
const cycle =
|
|
1845
|
-
if (cycle
|
|
1846
|
-
|
|
1847
|
-
|
|
2338
|
+
return undefined;
|
|
2339
|
+
const cycle = this.#waitCycle(current.id, targets, overlay);
|
|
2340
|
+
if (cycle === undefined)
|
|
2341
|
+
return undefined;
|
|
2342
|
+
return {
|
|
2343
|
+
error: new Error(`Task ${current.id} cannot wait on ${cycle[0]}: ${[current.id, ...cycle].join(" -> ")} is a wait cycle`),
|
|
2344
|
+
};
|
|
1848
2345
|
}
|
|
1849
2346
|
/**
|
|
1850
2347
|
* The path from a member of `on` back to `task` in the wait-for graph, if the wait would close a cycle (spec §5.1).
|
|
1851
2348
|
* An edge X -> Y means X cannot settle before Y: Y is in the `on` of waiting X that is not abort-marked, or Y is live
|
|
1852
|
-
* ordinary owned work of X,
|
|
1853
|
-
*
|
|
1854
|
-
*
|
|
2349
|
+
* ordinary owned work of X, which holds X as `completing`. Each task is visited once and each subtree walked once,
|
|
2350
|
+
* so a check is bounded by the live work it reaches. Part of a decision region: it records every record it reads.
|
|
2351
|
+
* Owned work is found by walks down, which need the chains of every live task loaded.
|
|
1855
2352
|
*/
|
|
1856
|
-
|
|
1857
|
-
// Owned work is found by walks down, which need the chains of every live task loaded.
|
|
1858
|
-
await this.#loadScopes(false);
|
|
2353
|
+
#waitCycle(task, on, overlay) {
|
|
1859
2354
|
const from = new Map();
|
|
1860
2355
|
const walked = new Set();
|
|
1861
2356
|
const pending = [];
|
|
@@ -1876,7 +2371,7 @@ export class TaskScheduler {
|
|
|
1876
2371
|
for (let id = pending.pop(); id !== undefined; id = pending.pop()) {
|
|
1877
2372
|
if (id === task)
|
|
1878
2373
|
return path(id);
|
|
1879
|
-
const record = overlay.tasks.get(id) ?? this.#
|
|
2374
|
+
const record = overlay.tasks.get(id) ?? this.#liveRecord(id);
|
|
1880
2375
|
if (record === undefined || record.state.status === "terminal")
|
|
1881
2376
|
continue;
|
|
1882
2377
|
// An abort-marked task waits only for its owned work, as abort runs bottom-up (`#waitingOn`).
|
|
@@ -1922,6 +2417,9 @@ export class TaskScheduler {
|
|
|
1922
2417
|
/** No live non-background task in the scope; a task whose owner edges are not loaded yet counts as inside. */
|
|
1923
2418
|
#idle(conversationId) {
|
|
1924
2419
|
const scope = conversationId === undefined ? { roots: true } : { conversation: conversationId };
|
|
2420
|
+
// In a decision region, global idle reads the roots it starts from.
|
|
2421
|
+
if (conversationId === undefined)
|
|
2422
|
+
this.#record(ROOTS_KEY);
|
|
1925
2423
|
// Tasks whose chain is not loaded are not listed below their scope yet; one counts as inside unless its walk up
|
|
1926
2424
|
// decides otherwise.
|
|
1927
2425
|
for (const id of this.#unloaded) {
|
|
@@ -1963,7 +2461,7 @@ export class TaskScheduler {
|
|
|
1963
2461
|
catch (error) {
|
|
1964
2462
|
if (invocation.controller.signal.aborted)
|
|
1965
2463
|
throw error;
|
|
1966
|
-
this.#report(error, {
|
|
2464
|
+
this.#report(error, { kind: "extension_error", extension, task: reportTask(invocation) }, invocation.context);
|
|
1967
2465
|
}
|
|
1968
2466
|
}
|
|
1969
2467
|
},
|
|
@@ -1986,8 +2484,9 @@ export class TaskScheduler {
|
|
|
1986
2484
|
get registry() {
|
|
1987
2485
|
return phase.snapshot();
|
|
1988
2486
|
},
|
|
1989
|
-
commit: async (change, context) => {
|
|
2487
|
+
commit: async (change, context, options) => {
|
|
1990
2488
|
let checkpoint;
|
|
2489
|
+
let interrupted;
|
|
1991
2490
|
await this.#gated(invocation, async (tx, current) => {
|
|
1992
2491
|
const next = await change(tx, current);
|
|
1993
2492
|
if (next !== undefined)
|
|
@@ -1995,7 +2494,30 @@ export class TaskScheduler {
|
|
|
1995
2494
|
if (next?.status === "running" && current.state.checkpoint.phase !== next.checkpoint.phase) {
|
|
1996
2495
|
checkpoint = next.checkpoint.phase;
|
|
1997
2496
|
}
|
|
1998
|
-
|
|
2497
|
+
// A task that ends for crashes says so (`task_interrupted`), so a host keeps a crash-loop signal.
|
|
2498
|
+
if (next?.status === "terminal") {
|
|
2499
|
+
const restarts = current.restarts ?? 0;
|
|
2500
|
+
const reason = next.outcome.status === "failed" ? interruptionOf(next.outcome.error) : undefined;
|
|
2501
|
+
if (reason !== undefined && next.outcome.status === "failed") {
|
|
2502
|
+
interrupted = { message: next.outcome.error.message, reason, restarts };
|
|
2503
|
+
}
|
|
2504
|
+
else {
|
|
2505
|
+
// A task that ended for crashes with another outcome says so itself, as a generation whose run
|
|
2506
|
+
// answered ends `completed` without its after-run tasks.
|
|
2507
|
+
const message = endedByCrashesStates.get(next);
|
|
2508
|
+
if (message !== undefined)
|
|
2509
|
+
interrupted = { message, reason: "restart_limit", restarts };
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
}, context, options);
|
|
2513
|
+
if (interrupted !== undefined) {
|
|
2514
|
+
this.#report(new Error(interrupted.message), {
|
|
2515
|
+
kind: "task_interrupted",
|
|
2516
|
+
...this.#aboutTask(invocation),
|
|
2517
|
+
reason: interrupted.reason,
|
|
2518
|
+
restarts: interrupted.restarts,
|
|
2519
|
+
}, invocation.context);
|
|
2520
|
+
}
|
|
1999
2521
|
if (checkpoint !== undefined) {
|
|
2000
2522
|
invocation.context.telemetry.addEvent("pi.durable.checkpoint", {
|
|
2001
2523
|
phase: checkpoint,
|
|
@@ -2019,7 +2541,7 @@ export class TaskScheduler {
|
|
|
2019
2541
|
watchDoc: ((...args) => this.#watchDoc(invocation, args)),
|
|
2020
2542
|
snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))),
|
|
2021
2543
|
snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))),
|
|
2022
|
-
getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine(() =>
|
|
2544
|
+
getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine((storage) => storage.task(id, context)))),
|
|
2023
2545
|
waitForTask: ((id, context) => this.#read(invocation, () => this.waitForTask(id, withAbortSignal(invocation.controller.signal, context)))),
|
|
2024
2546
|
ownedTasks: (context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
|
|
2025
2547
|
// The step that ends the invocation may have run on the line before this.
|
|
@@ -2027,12 +2549,16 @@ export class TaskScheduler {
|
|
|
2027
2549
|
throw endedError(invocation);
|
|
2028
2550
|
context.abortSignal?.throwIfAborted();
|
|
2029
2551
|
// Every live task is listed under its parent node, its owner task when it has one.
|
|
2030
|
-
const owned =
|
|
2031
|
-
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2552
|
+
const owned = await this.#region(() => {
|
|
2553
|
+
this.#record(ownedKey(invocation.taskId));
|
|
2554
|
+
const records = [];
|
|
2555
|
+
for (const node of this.#below.get(invocation.taskId) ?? []) {
|
|
2556
|
+
const record = typeof node === "string" ? undefined : this.#liveRecord(node);
|
|
2557
|
+
if (record?.owner === invocation.taskId)
|
|
2558
|
+
records.push(record);
|
|
2559
|
+
}
|
|
2560
|
+
return records;
|
|
2561
|
+
});
|
|
2036
2562
|
// Copies, as from storage: the scheduler reads its own.
|
|
2037
2563
|
return owned
|
|
2038
2564
|
.sort((a, b) => a.id - b.id)
|
|
@@ -2040,7 +2566,7 @@ export class TaskScheduler {
|
|
|
2040
2566
|
})),
|
|
2041
2567
|
abortOwned: (id, context) => this.#read(invocation, async () => {
|
|
2042
2568
|
const bound = withAbortSignal(invocation.controller.signal, context);
|
|
2043
|
-
const record = await this.#session.readOnLine(() =>
|
|
2569
|
+
const record = await this.#session.readOnLine((storage) => storage.task(id, bound));
|
|
2044
2570
|
if (record?.owner !== invocation.taskId) {
|
|
2045
2571
|
throw new Error(`Task ${id} is not owned by task ${invocation.taskId}`);
|
|
2046
2572
|
}
|
|
@@ -2049,10 +2575,10 @@ export class TaskScheduler {
|
|
|
2049
2575
|
await this.abort(id, bound, true);
|
|
2050
2576
|
await this.waitForTask(id, bound);
|
|
2051
2577
|
}),
|
|
2052
|
-
outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
|
|
2578
|
+
outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async (storage) => {
|
|
2053
2579
|
const outcomes = [];
|
|
2054
2580
|
for (const id of ids) {
|
|
2055
|
-
const state = (await
|
|
2581
|
+
const state = (await storage.task(id, context))?.state;
|
|
2056
2582
|
if (state?.status !== "terminal")
|
|
2057
2583
|
throw new Error(`Task ${id} is not terminal`);
|
|
2058
2584
|
outcomes.push(state.outcome);
|
|
@@ -2071,13 +2597,24 @@ export class TaskScheduler {
|
|
|
2071
2597
|
? [undefined, args[0], args[1]]
|
|
2072
2598
|
: [args[0], args[1], args[2]];
|
|
2073
2599
|
return this.#read(invocation, async () => {
|
|
2074
|
-
|
|
2075
|
-
const
|
|
2600
|
+
// An entry of the conversation's kept context range is committed and visible: no Storage read.
|
|
2601
|
+
const kept = this.#contexts.get(invocation.conversationId);
|
|
2602
|
+
const entry = (kept === undefined ? undefined : rangeEntry(kept.range, id)) ??
|
|
2603
|
+
(await this.#session.readOnLine((storage) => storage.entry(invocation.conversationId, id, context)))
|
|
2604
|
+
?.entry;
|
|
2076
2605
|
return token === undefined || entry?.kind === token.kind ? entry : undefined;
|
|
2077
2606
|
});
|
|
2078
2607
|
}),
|
|
2608
|
+
entries: (query, limit, cursor, context) => this.#read(invocation, () => this.#session.readOnLine((storage) => storage.scanEntries(conversationEntries(invocation.conversationId, query), limit, cursor, context))),
|
|
2079
2609
|
context: (conversationId, context, options) => this.#read(invocation, async () => {
|
|
2080
|
-
const
|
|
2610
|
+
const read = await readContextFrom(this.#session, this.#storage, conversationId, context, options?.at, this.#contexts.get(conversationId)?.range);
|
|
2611
|
+
const { view } = read;
|
|
2612
|
+
// Checked again here, synchronously up to the install below: a commit published since the read returned
|
|
2613
|
+
// was not seen by `#advanceContexts`, so a range that missed it must not be advanced.
|
|
2614
|
+
const range = read.range?.current === true &&
|
|
2615
|
+
this.#session.knownLatestEntryId(conversationId) !== read.range.bounds.tail
|
|
2616
|
+
? { ...read.range, current: false }
|
|
2617
|
+
: read.range;
|
|
2081
2618
|
// Keep it unless the invocation ended or a concurrent read already kept a newer range.
|
|
2082
2619
|
const kept = this.#contexts.get(conversationId);
|
|
2083
2620
|
if (range !== undefined &&
|
|
@@ -2087,9 +2624,17 @@ export class TaskScheduler {
|
|
|
2087
2624
|
// A read of another, idle conversation starts or continues its retention period.
|
|
2088
2625
|
if (!this.#idle(conversationId)) {
|
|
2089
2626
|
this.#contexts.set(conversationId, { range, idleSince: undefined });
|
|
2627
|
+
this.#idleOrder.delete(conversationId);
|
|
2090
2628
|
}
|
|
2091
2629
|
else if (this.#contextRetentionMs() > 0) {
|
|
2092
|
-
|
|
2630
|
+
// An idle period that already started keeps its expiry and its place; a new one starts now.
|
|
2631
|
+
const idleSince = kept?.idleSince ?? this.#now();
|
|
2632
|
+
const expiresAt = kept?.idleSince !== undefined && kept.expiresAt !== undefined
|
|
2633
|
+
? kept.expiresAt
|
|
2634
|
+
: idleSince + this.#contextRetentionMs();
|
|
2635
|
+
this.#contexts.set(conversationId, { range, idleSince, expiresAt });
|
|
2636
|
+
if (kept?.idleSince === undefined)
|
|
2637
|
+
this.#markIdle(conversationId, idleSince);
|
|
2093
2638
|
this.#scheduleExpiry();
|
|
2094
2639
|
}
|
|
2095
2640
|
}
|
|
@@ -2121,13 +2666,22 @@ export class TaskScheduler {
|
|
|
2121
2666
|
// The task's own failure names the task's extension; any other names whose code failed, if anyone's.
|
|
2122
2667
|
const task = reportTask(invocation);
|
|
2123
2668
|
this.#report(error, from === undefined
|
|
2124
|
-
? {
|
|
2125
|
-
: from.
|
|
2126
|
-
? {
|
|
2127
|
-
: {
|
|
2669
|
+
? { kind: "task_error", ...this.#aboutTask(invocation) }
|
|
2670
|
+
: from.kind === "extension"
|
|
2671
|
+
? { kind: "extension_error", task, ...optionalExtension(from.extension) }
|
|
2672
|
+
: { kind: "host_error", callback: from.callback, task }, invocation.context);
|
|
2128
2673
|
},
|
|
2129
2674
|
};
|
|
2130
2675
|
}
|
|
2676
|
+
/**
|
|
2677
|
+
* On the line: wait until every commit admitted before is published, for a decision that reads what cannot be keyed,
|
|
2678
|
+
* every live task (open, inspection) or every task a conversation abort reaches.
|
|
2679
|
+
*/
|
|
2680
|
+
async #published() {
|
|
2681
|
+
const gate = this.#session.touch(ALL_KEYS);
|
|
2682
|
+
if (gate !== undefined)
|
|
2683
|
+
await gate;
|
|
2684
|
+
}
|
|
2131
2685
|
/** Run a committed-state read unless the invocation has ended. */
|
|
2132
2686
|
async #read(invocation, read) {
|
|
2133
2687
|
if (invocation.ended)
|
|
@@ -2135,7 +2689,7 @@ export class TaskScheduler {
|
|
|
2135
2689
|
return read();
|
|
2136
2690
|
}
|
|
2137
2691
|
/** Commit after rereading the task on the line and gating the invocation. */
|
|
2138
|
-
#gated(invocation, change, context) {
|
|
2692
|
+
#gated(invocation, change, context, options) {
|
|
2139
2693
|
if (invocation.ended)
|
|
2140
2694
|
return Promise.reject(endedError(invocation));
|
|
2141
2695
|
return this.#session.commitWith(async (tx) => {
|
|
@@ -2143,17 +2697,22 @@ export class TaskScheduler {
|
|
|
2143
2697
|
throw endedError(invocation);
|
|
2144
2698
|
if (this.#closing)
|
|
2145
2699
|
throw closedError(this.#session);
|
|
2146
|
-
const
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2700
|
+
const current = await this.#region(() => {
|
|
2701
|
+
const found = this.#liveRecord(invocation.taskId);
|
|
2702
|
+
if (found === undefined)
|
|
2703
|
+
throw new Error(`Task ${invocation.taskId} is terminal`);
|
|
2704
|
+
if (found.state.status !== "running")
|
|
2705
|
+
throw new Error(`Task ${invocation.taskId} is ${found.state.status}`);
|
|
2706
|
+
if (invocation.mode === "run" && found.abortRequested) {
|
|
2707
|
+
throw new Error(`Task ${invocation.taskId} has a durable abort mark`);
|
|
2708
|
+
}
|
|
2709
|
+
return found;
|
|
2710
|
+
});
|
|
2711
|
+
// The step that ends it may have run while the region waited.
|
|
2712
|
+
if (invocation.ended)
|
|
2713
|
+
throw endedError(invocation);
|
|
2155
2714
|
return change(tx, current);
|
|
2156
|
-
}, context, { conversationId: invocation.conversationId, taskId: invocation.taskId });
|
|
2715
|
+
}, context, { conversationId: invocation.conversationId, taskId: invocation.taskId }, options);
|
|
2157
2716
|
}
|
|
2158
2717
|
async #watchDoc(invocation, args) {
|
|
2159
2718
|
if (invocation.ended)
|
|
@@ -2170,9 +2729,10 @@ export class TaskScheduler {
|
|
|
2170
2729
|
return watch;
|
|
2171
2730
|
}
|
|
2172
2731
|
}
|
|
2173
|
-
const REGISTRY = {
|
|
2174
|
-
const SETTINGS = {
|
|
2175
|
-
const ON_WAKE = {
|
|
2732
|
+
const REGISTRY = { kind: "host_error", callback: "registry" };
|
|
2733
|
+
const SETTINGS = { kind: "host_error", callback: "settings" };
|
|
2734
|
+
const ON_WAKE = { kind: "host_error", callback: "onWake" };
|
|
2735
|
+
const TELEMETRY = { kind: "host_error", callback: "telemetry" };
|
|
2176
2736
|
/** The extension that installed `task` in `snapshot`, the last one that lists it; none for a built-in task. */
|
|
2177
2737
|
function installerOf(snapshot, task) {
|
|
2178
2738
|
if (task === undefined)
|
|
@@ -2191,6 +2751,24 @@ function recordTask(record) {
|
|
|
2191
2751
|
* The record with an abort mark: `restart` for an abandonment; without a reason, a request, which replaces `restart`.
|
|
2192
2752
|
* The restart count starts over: it now counts cut-offs of the abort handler.
|
|
2193
2753
|
*/
|
|
2754
|
+
/** Why a failed outcome's error says crashes ended the task (`detail.reason`), if it does. */
|
|
2755
|
+
/**
|
|
2756
|
+
* Terminal states a built-in task commits because it was cut off `settings.restartLimit` times, when their outcome does
|
|
2757
|
+
* not say so (a failure with detail `{ reason: "restart_limit" }` does), with the message the scheduler reports them
|
|
2758
|
+
* `task_interrupted` with. Keyed by the state object the task returns, so nothing is added to the stored state.
|
|
2759
|
+
*/
|
|
2760
|
+
const endedByCrashesStates = new WeakMap();
|
|
2761
|
+
/** `state`, marked as ending its task for crashes (`task_interrupted` with `message`), whatever its outcome. */
|
|
2762
|
+
export function endedByCrashes(state, message) {
|
|
2763
|
+
endedByCrashesStates.set(state, message);
|
|
2764
|
+
return state;
|
|
2765
|
+
}
|
|
2766
|
+
function interruptionOf(error) {
|
|
2767
|
+
const detail = error.detail;
|
|
2768
|
+
if (typeof detail !== "object" || detail === null || !("reason" in detail))
|
|
2769
|
+
return undefined;
|
|
2770
|
+
return detail.reason === "restart_limit" || detail.reason === "interrupted" ? detail.reason : undefined;
|
|
2771
|
+
}
|
|
2194
2772
|
function withAbortMark(record, reason) {
|
|
2195
2773
|
const { abortReason: _reason, restarts: _restarts, ...rest } = record;
|
|
2196
2774
|
return { ...rest, abortRequested: true, ...(reason === undefined ? {} : { abortReason: reason }) };
|
|
@@ -2199,6 +2777,16 @@ function withAbortMark(record, reason) {
|
|
|
2199
2777
|
function cancellationIntent(record) {
|
|
2200
2778
|
return record.state.status !== "terminal" && (record.abortRequested || failedOutcome(record));
|
|
2201
2779
|
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Whether replacing `previous` (absent: a new task) with `next` changes the cancellation intent set or what it means: a
|
|
2782
|
+
* mark set, upgraded, or cleared, or a held or ended outcome other than `completed`.
|
|
2783
|
+
*/
|
|
2784
|
+
function intentChanges(previous, next) {
|
|
2785
|
+
return ((previous?.abortRequested ?? false) !== next.abortRequested ||
|
|
2786
|
+
previous?.abortReason !== next.abortReason ||
|
|
2787
|
+
(previous !== undefined && failedOutcome(previous)) !== failedOutcome(next) ||
|
|
2788
|
+
(previous !== undefined && cancellationIntent(previous)) !== cancellationIntent(next));
|
|
2789
|
+
}
|
|
2202
2790
|
/** Whether the record holds or ends with an outcome other than `completed`. */
|
|
2203
2791
|
function failedOutcome(record) {
|
|
2204
2792
|
const state = record.state;
|
|
@@ -2267,6 +2855,15 @@ function withState(record, state) {
|
|
|
2267
2855
|
return { ...rest, state };
|
|
2268
2856
|
}
|
|
2269
2857
|
/** Whether a definition can take the task at reservation: same version, or newer with a migration. */
|
|
2858
|
+
/**
|
|
2859
|
+
* Why a replacement definition cannot take a running task, in `#fit()`'s words: none is registered, it is older than
|
|
2860
|
+
* the task, or it is newer without a `migrate`.
|
|
2861
|
+
*/
|
|
2862
|
+
function outdatedReason(task, record) {
|
|
2863
|
+
if (task === undefined)
|
|
2864
|
+
return "missing_task";
|
|
2865
|
+
return task.definition.version < record.version ? "task_too_old" : "migration_failed";
|
|
2866
|
+
}
|
|
2270
2867
|
function canReserve(task, record) {
|
|
2271
2868
|
const definition = task.definition;
|
|
2272
2869
|
return (definition.version === record.version || (definition.version > record.version && definition.migrate !== undefined));
|