@zingage/scheduling 1.1.0 → 1.2.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/README.md +184 -5
- package/dist/batcher.d.ts +231 -0
- package/dist/batcher.d.ts.map +1 -0
- package/dist/batcher.js +391 -0
- package/dist/batcher.js.map +1 -0
- package/dist/collapsed-task-creator.d.ts +235 -0
- package/dist/collapsed-task-creator.d.ts.map +1 -0
- package/dist/collapsed-task-creator.js +491 -0
- package/dist/collapsed-task-creator.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/pending-promises-tracker.d.ts +32 -0
- package/dist/pending-promises-tracker.d.ts.map +1 -0
- package/dist/pending-promises-tracker.js +60 -0
- package/dist/pending-promises-tracker.js.map +1 -0
- package/dist/performance-now.d.ts +14 -0
- package/dist/performance-now.d.ts.map +1 -0
- package/dist/performance-now.js +10 -0
- package/dist/performance-now.js.map +1 -0
- package/dist/scheduling-contract-error.d.ts +9 -0
- package/dist/scheduling-contract-error.d.ts.map +1 -0
- package/dist/scheduling-contract-error.js +9 -0
- package/dist/scheduling-contract-error.js.map +1 -0
- package/dist/wrap-with-task-collapsing.d.ts +30 -0
- package/dist/wrap-with-task-collapsing.d.ts.map +1 -0
- package/dist/wrap-with-task-collapsing.js +80 -0
- package/dist/wrap-with-task-collapsing.js.map +1 -0
- package/package.json +10 -4
- package/src/batcher.ts +614 -0
- package/src/collapsed-task-creator.ts +774 -0
- package/src/index.ts +22 -0
- package/src/logger.ts +32 -0
- package/src/pending-promises-tracker.ts +62 -0
- package/src/performance-now.ts +21 -0
- package/src/scheduling-contract-error.ts +8 -0
- package/src/wrap-with-task-collapsing.ts +103 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import { partition } from "es-toolkit";
|
|
2
|
+
import { SchedulingContractError } from "./scheduling-contract-error.js";
|
|
3
|
+
|
|
4
|
+
/** A task as the creator shows it to `schedule`. */
|
|
5
|
+
export type SharedTaskView<Input> = Readonly<
|
|
6
|
+
Pick<
|
|
7
|
+
TaskStart<Input>,
|
|
8
|
+
"input" | "signal" | "collapsedRequestCount" | "startReason"
|
|
9
|
+
>
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
type TaskRequest<Input> = { input: Input; signal?: AbortSignal | undefined };
|
|
13
|
+
|
|
14
|
+
/** What `startOrRideTasks` returns for one task request. */
|
|
15
|
+
export type TaskRequestOutcome<Result, Handle> = {
|
|
16
|
+
readonly result: Promise<Result>;
|
|
17
|
+
/**
|
|
18
|
+
* The handle of the request's task. Undefined for a request that was
|
|
19
|
+
* already cancelled, which gets no task.
|
|
20
|
+
*/
|
|
21
|
+
readonly handle: Handle | undefined;
|
|
22
|
+
readonly rode: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What `startOrRideTasks` returns: one outcome per task request, at the same
|
|
27
|
+
* position. A tuple of requests gives a tuple of outcomes of the same length.
|
|
28
|
+
*/
|
|
29
|
+
export type TaskRequestOutcomes<
|
|
30
|
+
Requests extends readonly unknown[],
|
|
31
|
+
Result,
|
|
32
|
+
Handle,
|
|
33
|
+
> = { -readonly [K in keyof Requests]: TaskRequestOutcome<Result, Handle> };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The `schedule` option of a {@link CollapsedTaskCreator}. `context` is the value the
|
|
37
|
+
* `startOrRideTasks` call passed.
|
|
38
|
+
*/
|
|
39
|
+
export type ScheduleTasks<Input, Result, Handle, Context = void> = (
|
|
40
|
+
toStart: readonly SharedTaskView<Input>[],
|
|
41
|
+
context: Context,
|
|
42
|
+
) => readonly Readonly<{ result: Promise<Result>; handle: Handle }>[];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Why a task request started a task instead of riding one:
|
|
46
|
+
* - `noKey`: `makeKey` returned `undefined`, so the request never shares work.
|
|
47
|
+
* - `noPendingTask`: no task for its key was pending.
|
|
48
|
+
* - `pendingTaskTooOld`: the pending task for its key started more than
|
|
49
|
+
* `collapseTasksMs` ago.
|
|
50
|
+
* - `pendingTaskCancelled`: the pending task for its key was cancelled.
|
|
51
|
+
*/
|
|
52
|
+
export type TaskStartReason =
|
|
53
|
+
| "noKey"
|
|
54
|
+
| "noPendingTask"
|
|
55
|
+
| "pendingTaskTooOld"
|
|
56
|
+
| "pendingTaskCancelled";
|
|
57
|
+
|
|
58
|
+
/** The parts of a task that exist before its `schedule` call returns. */
|
|
59
|
+
type TaskStart<Input> = {
|
|
60
|
+
/**
|
|
61
|
+
* Why the task request that started the task did not ride one. It is for
|
|
62
|
+
* diagnostics, for example a log line that `schedule` writes.
|
|
63
|
+
*/
|
|
64
|
+
readonly startReason: TaskStartReason;
|
|
65
|
+
/**
|
|
66
|
+
* Compared with `collapseTasksMs`, so no task request rides a task that is
|
|
67
|
+
* too old.
|
|
68
|
+
*/
|
|
69
|
+
readonly startedAt: number;
|
|
70
|
+
/**
|
|
71
|
+
* The input of the task request that STARTED the task. Task requests that
|
|
72
|
+
* ride the task never replace it, so per-request labels such as a producer
|
|
73
|
+
* trigger keep the starting task request's value.
|
|
74
|
+
*/
|
|
75
|
+
readonly input: Input;
|
|
76
|
+
/**
|
|
77
|
+
* Aborts when every request on the task is cancelled. A task request is
|
|
78
|
+
* cancelled when its own signal aborts. A task request without a signal can
|
|
79
|
+
* never be cancelled, so it keeps the task's signal from aborting. Its
|
|
80
|
+
* reason is the signal reason of the last request to be cancelled.
|
|
81
|
+
*
|
|
82
|
+
* Undefined when the request that started the task has no signal: the task
|
|
83
|
+
* can never be cancelled, so it needs no signal, and `schedule` skips any
|
|
84
|
+
* cancellation work. The converse does not hold. A defined signal can still
|
|
85
|
+
* never abort, when a later request without a signal makes the task
|
|
86
|
+
* uncancellable after `schedule` received the signal.
|
|
87
|
+
*
|
|
88
|
+
* The lookup in `startOrRideTasks` also reads it, so no request rides a
|
|
89
|
+
* cancelled task.
|
|
90
|
+
*/
|
|
91
|
+
readonly signal: AbortSignal | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* The number of task requests that have ridden the task so far. Each one is
|
|
94
|
+
* a request that sharing served without starting new work. The task request
|
|
95
|
+
* that started the task does not count, and a cancelled rider still does.
|
|
96
|
+
*
|
|
97
|
+
* `schedule` receives this object itself, not a copy, so a reader sees
|
|
98
|
+
* every ride up to the time it reads the field. A call records its rides
|
|
99
|
+
* only after its `schedule` returns, so `schedule` never sees its own
|
|
100
|
+
* call's rides. Only the creator writes it: `SharedTaskView` makes it
|
|
101
|
+
* readonly.
|
|
102
|
+
*/
|
|
103
|
+
collapsedRequestCount: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A task that its `schedule` call has scheduled. Only these tasks are in
|
|
108
|
+
* `#latestPendingTaskByKey` and `#live`, so a failed `schedule` call leaves no
|
|
109
|
+
* task behind.
|
|
110
|
+
*/
|
|
111
|
+
class Task<Input, Result, Handle> {
|
|
112
|
+
/** The parts of the task that existed before its `schedule` call returned. */
|
|
113
|
+
readonly start: TaskStart<Input>;
|
|
114
|
+
/**
|
|
115
|
+
* The handle that the task's `schedule` call returned. `startOrRideTasks`
|
|
116
|
+
* returns it to every request on the task.
|
|
117
|
+
*/
|
|
118
|
+
readonly handle: Handle;
|
|
119
|
+
/** The result that the task's `schedule` call returned. */
|
|
120
|
+
readonly result: Promise<Result>;
|
|
121
|
+
/**
|
|
122
|
+
* Undefined once the task can never be cancelled: from the start when its
|
|
123
|
+
* starting request has no signal, after a later request without a signal
|
|
124
|
+
* (see `SharedTaskView.signal`), or once the task settles. In the last two
|
|
125
|
+
* cases, the task keeps the signal that `schedule` already received, but
|
|
126
|
+
* nothing can abort it any more.
|
|
127
|
+
*/
|
|
128
|
+
#cancellation:
|
|
129
|
+
| {
|
|
130
|
+
/** Aborts the task's unique `start.signal`. */
|
|
131
|
+
readonly controller: AbortController;
|
|
132
|
+
/**
|
|
133
|
+
* The task's requests that are not cancelled, plus the number of
|
|
134
|
+
* `hold()` calls on the task. Every one of those requests has a signal:
|
|
135
|
+
* a request without one would clear `#cancellation` instead. The task's
|
|
136
|
+
* signal aborts when this reaches 0.
|
|
137
|
+
*/
|
|
138
|
+
uncancelledRequests: number;
|
|
139
|
+
/**
|
|
140
|
+
* The signal reason of the last request to be cancelled. The task's
|
|
141
|
+
* signal aborts with it. Stored because a hold can delay the abort
|
|
142
|
+
* until after that request's cancellation.
|
|
143
|
+
*/
|
|
144
|
+
lastCancelReason: unknown;
|
|
145
|
+
/**
|
|
146
|
+
* The abort listeners that the task (via `addRequest`) added to
|
|
147
|
+
* individual requests' signals. They are removed when the task becomes
|
|
148
|
+
* uncancellable: a task request's signal can outlive the task, and its
|
|
149
|
+
* listener would otherwise keep the task alive. Kept here because
|
|
150
|
+
* listeners exist only while the task can be cancelled.
|
|
151
|
+
*/
|
|
152
|
+
readonly abortListeners: [signal: AbortSignal, listener: () => void][];
|
|
153
|
+
}
|
|
154
|
+
| undefined;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param controller The controller whose signal is `start.signal`, or
|
|
158
|
+
* undefined when the starting request has no signal.
|
|
159
|
+
* @param onSettle Called once, when `result` settles, after the task has
|
|
160
|
+
* become uncancellable and removed its abort listeners.
|
|
161
|
+
*/
|
|
162
|
+
constructor(
|
|
163
|
+
start: TaskStart<Input>,
|
|
164
|
+
controller: AbortController | undefined,
|
|
165
|
+
{ result, handle }: { result: Promise<Result>; handle: Handle },
|
|
166
|
+
onSettle: () => void,
|
|
167
|
+
) {
|
|
168
|
+
this.start = start;
|
|
169
|
+
this.#cancellation = controller && {
|
|
170
|
+
controller,
|
|
171
|
+
uncancelledRequests: 0,
|
|
172
|
+
lastCancelReason: undefined,
|
|
173
|
+
abortListeners: [],
|
|
174
|
+
};
|
|
175
|
+
this.handle = handle;
|
|
176
|
+
this.result = result;
|
|
177
|
+
|
|
178
|
+
// Handling both outcomes also marks a rejection as handled: every task
|
|
179
|
+
// request may be cancelled before anyone awaits the result.
|
|
180
|
+
const settle = () => {
|
|
181
|
+
this.#makeUncancelable();
|
|
182
|
+
onSettle();
|
|
183
|
+
};
|
|
184
|
+
// oxlint-disable-next-line no-void -- Runs settle bookkeeping; callers await the result themselves.
|
|
185
|
+
void result.then(settle, settle);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Keeps the task's signal from aborting until `release`, even if every
|
|
190
|
+
* request on the task is cancelled meanwhile. `startOrRideTasks` holds each
|
|
191
|
+
* task it starts or rides until it has recorded all of its requests, so a
|
|
192
|
+
* cancellation during `schedule`, or during another task's abort, cannot
|
|
193
|
+
* cancel a task that a request of the call will keep alive.
|
|
194
|
+
*
|
|
195
|
+
* A hold counts as one uncancelled request. Holds can nest, because a
|
|
196
|
+
* nested `startOrRideTasks` call can ride a task that an outer call holds.
|
|
197
|
+
* It is inert on an uncancellable task, which has no signal to abort.
|
|
198
|
+
*/
|
|
199
|
+
hold() {
|
|
200
|
+
if (this.#cancellation) this.#cancellation.uncancelledRequests += 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Ends one `hold`. Aborts the task's signal if no request keeps the task
|
|
205
|
+
* alive. Like `hold`, it is inert on an uncancellable task.
|
|
206
|
+
*/
|
|
207
|
+
release() {
|
|
208
|
+
const cancellation = this.#cancellation;
|
|
209
|
+
if (cancellation === undefined) return;
|
|
210
|
+
cancellation.uncancelledRequests -= 1;
|
|
211
|
+
if (cancellation.uncancelledRequests === 0) {
|
|
212
|
+
cancellation.controller.abort(cancellation.lastCancelReason);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Records a task request on this task, whether the request started the
|
|
218
|
+
* task or rides it. `signal` and `rode` are the request's. Call it only
|
|
219
|
+
* while holding the task: a request whose signal already aborted is not
|
|
220
|
+
* counted, so only `release` can abort the task for it.
|
|
221
|
+
*/
|
|
222
|
+
addRequest(signal: AbortSignal | undefined, rode: boolean) {
|
|
223
|
+
if (rode) this.start.collapsedRequestCount += 1;
|
|
224
|
+
|
|
225
|
+
// An uncancellable task needs no count: no task request can cancel it.
|
|
226
|
+
const cancellation = this.#cancellation;
|
|
227
|
+
if (cancellation === undefined) return;
|
|
228
|
+
|
|
229
|
+
// A request w/o a signal can never be cancelled, so neither can the task.
|
|
230
|
+
if (signal === undefined) {
|
|
231
|
+
this.#makeUncancelable();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// `schedule` runs user code, so a signal can abort after `startOrRideTasks`
|
|
236
|
+
// checked it.
|
|
237
|
+
if (signal.aborted) {
|
|
238
|
+
cancellation.lastCancelReason = signal.reason;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
cancellation.uncancelledRequests += 1;
|
|
243
|
+
const listener = () => {
|
|
244
|
+
cancellation.lastCancelReason = signal.reason;
|
|
245
|
+
this.release();
|
|
246
|
+
};
|
|
247
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
248
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- The task owns its listener list, and removes every listener later.
|
|
249
|
+
cancellation.abortListeners.push([signal, listener]);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Clears `#cancellation`, and removes its abort listeners, which can no
|
|
254
|
+
* longer abort the task. Calling it again does nothing.
|
|
255
|
+
*/
|
|
256
|
+
#makeUncancelable() {
|
|
257
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Removes each listener for its side effect.
|
|
258
|
+
this.#cancellation?.abortListeners.forEach(([signal, listener]) => {
|
|
259
|
+
signal.removeEventListener("abort", listener);
|
|
260
|
+
});
|
|
261
|
+
this.#cancellation = undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Builds a task's start, and the controller that aborts its signal. The
|
|
267
|
+
* controller stays out of the start because `schedule` receives the start,
|
|
268
|
+
* and only the task may abort its own signal.
|
|
269
|
+
*/
|
|
270
|
+
function newTaskStart<Input>(
|
|
271
|
+
input: Input,
|
|
272
|
+
cancellable: boolean,
|
|
273
|
+
now: number,
|
|
274
|
+
startReason: TaskStartReason,
|
|
275
|
+
): { start: TaskStart<Input>; controller: AbortController | undefined } {
|
|
276
|
+
const controller = cancellable ? new AbortController() : undefined;
|
|
277
|
+
return {
|
|
278
|
+
start: {
|
|
279
|
+
startReason,
|
|
280
|
+
startedAt: now,
|
|
281
|
+
input,
|
|
282
|
+
signal: controller?.signal,
|
|
283
|
+
collapsedRequestCount: 0,
|
|
284
|
+
},
|
|
285
|
+
controller,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Lets requests for the same work share one pending task, with optional
|
|
291
|
+
* cancellation.
|
|
292
|
+
*
|
|
293
|
+
* Imagine you have a function that, when called, kicks off a task, and
|
|
294
|
+
* returns a promise representing the result of the task. Now, suppose the task
|
|
295
|
+
* is expensive, but not effectful, like fetching from a REST API. If your code
|
|
296
|
+
* attempts to run this task many times in quick succession, or many times in
|
|
297
|
+
* parallel, this can be wasteful (if the result will always or likely be the
|
|
298
|
+
* same) and/or overload whatever's doing work as part of the task (eg the API).
|
|
299
|
+
*
|
|
300
|
+
* There are a large number of npm packages that make it easy to better schedule
|
|
301
|
+
* this work (e.g., queueing some parallel runs of the task behind others, if
|
|
302
|
+
* the number of tasks already running exceeds some concurrency limit) or to
|
|
303
|
+
* reuse the results of some runs of the task for others (by debouncing or
|
|
304
|
+
* throttling calls to the function that starts the task). However, none of them
|
|
305
|
+
* have quite the -- admittedly a bit quirky -- semantics that we want, and that
|
|
306
|
+
* we implement here.
|
|
307
|
+
*
|
|
308
|
+
* Specifically, a task request reuses a pending task, instead of starting a
|
|
309
|
+
* new one, if and only if: 1) its input has the same key as the task's input
|
|
310
|
+
* [a la memoize]; 2) the task started no more than `collapseTasksMs` ago [a la
|
|
311
|
+
* throttle]; and 3) the task is still running [unique], and is not cancelled.
|
|
312
|
+
* In other words, it memoizes task creation, but then reverts to starting a
|
|
313
|
+
* new task after a certain number of milliseconds or after the last-started
|
|
314
|
+
* task finishes, whichever is first. So it's sort of a combination of memoize,
|
|
315
|
+
* throttle, and promise chaining.
|
|
316
|
+
*
|
|
317
|
+
* Beyond that collapsing, it adds three things:
|
|
318
|
+
*
|
|
319
|
+
* - One `startOrRideTasks` call takes many task requests. Each task request
|
|
320
|
+
* either starts a new task or rides a pending task for its input's key. The
|
|
321
|
+
* tasks that one call starts are scheduled together, in one `schedule` call,
|
|
322
|
+
* so a bulk caller's new inputs become one unit of work. Task requests that
|
|
323
|
+
* ride start nothing.
|
|
324
|
+
* - Each task has an optional AbortSignal that aborts when every request on the
|
|
325
|
+
* task is cancelled. `schedule` passes it to whatever runs the work, such as
|
|
326
|
+
* a batcher's queue or a producer that supports cancellation. See
|
|
327
|
+
* `SharedTaskView.signal`.
|
|
328
|
+
* - It counts the task requests that ride each task, for diagnostics.
|
|
329
|
+
*
|
|
330
|
+
* For the simple case, with one call per task request and no cancellation,
|
|
331
|
+
* `wrapWithTaskCollapsing` wraps a task-starting function with this class.
|
|
332
|
+
*
|
|
333
|
+
* Terms:
|
|
334
|
+
* - An input is the value that a task's work needs, such as a producer request.
|
|
335
|
+
* `makeKey` derives its key.
|
|
336
|
+
* - A task request is one input plus an optional signal that cancels the task
|
|
337
|
+
* request. It is one item of `startOrRideTasks`'s `taskRequests`.
|
|
338
|
+
* - A task request starts a task or rides one. A task's requests are the task
|
|
339
|
+
* request that started it and every task request that rides it.
|
|
340
|
+
* - A task request is cancelled when its signal aborts. A task is cancelled
|
|
341
|
+
* when all of its requests are cancelled.
|
|
342
|
+
*
|
|
343
|
+
* The creator itself does not queue, batch or limit work. The `schedule`
|
|
344
|
+
* callback owns that.
|
|
345
|
+
*
|
|
346
|
+
* A task stops being ridable when it settles, when it is cancelled, or when it
|
|
347
|
+
* started more than `collapseTasksMs` ago. A cancelled task can still be
|
|
348
|
+
* running, but a new task request must not ride work that is being cancelled.
|
|
349
|
+
*/
|
|
350
|
+
export class CollapsedTaskCreator<
|
|
351
|
+
Input,
|
|
352
|
+
Result,
|
|
353
|
+
Handle,
|
|
354
|
+
Key = string,
|
|
355
|
+
Context = void,
|
|
356
|
+
> {
|
|
357
|
+
readonly #collapseTasksMs: number;
|
|
358
|
+
readonly #makeKey: (input: Input) => Key | undefined;
|
|
359
|
+
readonly #schedule: ScheduleTasks<Input, Result, Handle, Context>;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* For each key, the newest pending task: the task that a new task request
|
|
363
|
+
* for that key rides, unless it is too old or cancelled. A cancelled task
|
|
364
|
+
* stays here until it settles or a newer task replaces it; the lookup in
|
|
365
|
+
* `startOrRideTasks` checks its signal, so no request rides it. A newer
|
|
366
|
+
* task replaces an older one whose start is outside `collapseTasksMs`, or
|
|
367
|
+
* a cancelled one; the replaced task keeps running, tracked only in
|
|
368
|
+
* `#live`.
|
|
369
|
+
*/
|
|
370
|
+
readonly #latestPendingTaskByKey = new Map<
|
|
371
|
+
Key,
|
|
372
|
+
Task<Input, Result, Handle>
|
|
373
|
+
>();
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Every task that has not settled. `#latestPendingTaskByKey` leaves out two
|
|
377
|
+
* kinds of unsettled task: tasks with no key, and tasks that a newer task
|
|
378
|
+
* replaced. `drain` must still wait for all of them.
|
|
379
|
+
*/
|
|
380
|
+
readonly #live = new Set<Task<Input, Result, Handle>>();
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The keys of the tasks that a running `schedule` call is starting. Those
|
|
384
|
+
* tasks enter `#latestPendingTaskByKey` only after `schedule` returns. So
|
|
385
|
+
* when `schedule` runs a producer synchronously, and that producer requests
|
|
386
|
+
* its own key, a nested call could not ride the task and would start it
|
|
387
|
+
* again, without end. The nested call throws instead.
|
|
388
|
+
*
|
|
389
|
+
* Tasks with no key are never reserved, because nothing can ride them. A
|
|
390
|
+
* key stays here only while its `schedule` call runs. The set holds keys
|
|
391
|
+
* from several calls only while `schedule` calls nest, with different keys.
|
|
392
|
+
*/
|
|
393
|
+
readonly #schedulingKeys = new Set<Key>();
|
|
394
|
+
|
|
395
|
+
constructor(options: {
|
|
396
|
+
/**
|
|
397
|
+
* A task request rides a pending task for its input's key only if that
|
|
398
|
+
* task started no more than this many milliseconds ago.
|
|
399
|
+
*/
|
|
400
|
+
collapseTasksMs: number;
|
|
401
|
+
/**
|
|
402
|
+
* Converts an input into the key that decides which pending task its task
|
|
403
|
+
* request may ride. Return `undefined` for an input that must not share
|
|
404
|
+
* work with any other, for example because its key cannot be computed.
|
|
405
|
+
* Its task request then starts its own task, which no task request can
|
|
406
|
+
* ride. Keys match as `Map` keys do (SameValueZero), so an object key
|
|
407
|
+
* matches only itself.
|
|
408
|
+
*/
|
|
409
|
+
makeKey: (input: Input) => Key | undefined;
|
|
410
|
+
/**
|
|
411
|
+
* Schedules the tasks that one `startOrRideTasks` call starts, in task
|
|
412
|
+
* request order, as one unit of work. It returns one entry per task, in
|
|
413
|
+
* order: the task's result, and an opaque handle that every request on
|
|
414
|
+
* the task gets back.
|
|
415
|
+
*
|
|
416
|
+
* Every call uses this same function. It receives the `context` that the
|
|
417
|
+
* `startOrRideTasks` call passed, for example to learn how the caller
|
|
418
|
+
* wants the work run: batched with later calls, or at once. Usually it
|
|
419
|
+
* only queues the work and arms a flush. A caller that wants the work run
|
|
420
|
+
* at once then dispatches it, using the handles that `startOrRideTasks`
|
|
421
|
+
* returns. Those handles also cover the queued tasks the call rode, so
|
|
422
|
+
* one dispatch can cover all of the call's work.
|
|
423
|
+
*
|
|
424
|
+
* A `schedule` that runs work synchronously may call `startOrRideTasks`
|
|
425
|
+
* again, for example through a producer that reads other keys. See
|
|
426
|
+
* `startOrRideTasks` for the limit on that.
|
|
427
|
+
*/
|
|
428
|
+
schedule: ScheduleTasks<Input, Result, Handle, Context>;
|
|
429
|
+
}) {
|
|
430
|
+
this.#collapseTasksMs = options.collapseTasksMs;
|
|
431
|
+
this.#makeKey = options.makeKey;
|
|
432
|
+
this.#schedule = options.schedule;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Finds the pending task that a task request for `key` may ride, or says
|
|
437
|
+
* why there is none. A task that is both too old and cancelled reports
|
|
438
|
+
* `pendingTaskTooOld`.
|
|
439
|
+
*/
|
|
440
|
+
#findRidable(
|
|
441
|
+
now: number,
|
|
442
|
+
key: Key,
|
|
443
|
+
):
|
|
444
|
+
| { ride: Task<Input, Result, Handle> }
|
|
445
|
+
| { startReason: Exclude<TaskStartReason, "noKey"> } {
|
|
446
|
+
const existing = this.#latestPendingTaskByKey.get(key);
|
|
447
|
+
if (!existing) return { startReason: "noPendingTask" };
|
|
448
|
+
const { signal, startedAt } = existing.start;
|
|
449
|
+
if (now - startedAt > this.#collapseTasksMs) {
|
|
450
|
+
return { startReason: "pendingTaskTooOld" };
|
|
451
|
+
}
|
|
452
|
+
if (signal?.aborted) return { startReason: "pendingTaskCancelled" };
|
|
453
|
+
return { ride: existing };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#setupNewTask(
|
|
457
|
+
start: TaskStart<Input>,
|
|
458
|
+
controller: AbortController | undefined,
|
|
459
|
+
scheduleResult: Readonly<{ result: Promise<Result>; handle: Handle }>,
|
|
460
|
+
key: Key | undefined,
|
|
461
|
+
) {
|
|
462
|
+
const task = new Task(start, controller, scheduleResult, () => {
|
|
463
|
+
this.#live.delete(task);
|
|
464
|
+
// Only remove this task if the map still holds it for its key. The map
|
|
465
|
+
// could hold a newer one if this task was replaced for being too old or
|
|
466
|
+
// cancelled.
|
|
467
|
+
if (key !== undefined && this.#latestPendingTaskByKey.get(key) === task) {
|
|
468
|
+
this.#latestPendingTaskByKey.delete(key);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
this.#live.add(task);
|
|
473
|
+
|
|
474
|
+
// If the key is defined, this task can be the existing pending task
|
|
475
|
+
// for future requests with the same key, so register it.
|
|
476
|
+
if (key !== undefined) {
|
|
477
|
+
this.#latestPendingTaskByKey.set(key, task);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return task;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Each task request rides a pending task for its input's key, or starts a
|
|
485
|
+
* new task. Schedules the new tasks with one `schedule` call. When every
|
|
486
|
+
* task request rides, it does not call `schedule`.
|
|
487
|
+
*
|
|
488
|
+
* A task request whose signal is already aborted, or aborts during
|
|
489
|
+
* `makeKey`, starts and rides nothing. Its `result` rejects with the
|
|
490
|
+
* signal's reason, and the call's other task requests are not affected.
|
|
491
|
+
*
|
|
492
|
+
* It changes no state until `schedule` returns, except that it holds the
|
|
493
|
+
* tasks it rides. So when `makeKey` throws, `schedule` throws, or
|
|
494
|
+
* `schedule` returns the wrong number of entries, it releases those tasks,
|
|
495
|
+
* throws, and changes nothing else. It also holds
|
|
496
|
+
* each task it starts until it has recorded all of its task requests (see
|
|
497
|
+
* `Task.hold`).
|
|
498
|
+
*
|
|
499
|
+
* The tasks this call starts become ridable only after `schedule`
|
|
500
|
+
* returns. A `schedule` may call `startOrRideTasks` again, for example by
|
|
501
|
+
* running a producer that reads other keys. But a nested call that
|
|
502
|
+
* requests a key this call is starting throws a `SchedulingContractError`:
|
|
503
|
+
* that request depends on its own result.
|
|
504
|
+
*
|
|
505
|
+
* @param taskRequests Each task request's input, and an optional signal
|
|
506
|
+
* that cancels the task request.
|
|
507
|
+
* @param context Passed unchanged to `schedule`, if this call starts any
|
|
508
|
+
* task. A nested call passes its own context. A creator whose `schedule`
|
|
509
|
+
* needs none keeps the default `Context`, `void`, so its callers omit
|
|
510
|
+
* it. TypeScript lets a caller omit a trailing argument only when its
|
|
511
|
+
* type accepts `void`; `undefined` does not count.
|
|
512
|
+
* @returns One entry per task request, in order. `result` is the task's
|
|
513
|
+
* shared result. It does not reject when only this task request is
|
|
514
|
+
* cancelled, because the task's other requests may still need the
|
|
515
|
+
* result; a caller races its own signal. `handle` is the handle that the
|
|
516
|
+
* task's `schedule` returned. A caller that wants its work run at once
|
|
517
|
+
* can dispatch the still-queued tasks behind these handles, including
|
|
518
|
+
* the tasks it rode. It is undefined for a cancelled task request.
|
|
519
|
+
* `rode` is true when the task request rode a task that it did not
|
|
520
|
+
* start.
|
|
521
|
+
*/
|
|
522
|
+
startOrRideTasks<
|
|
523
|
+
const Requests extends readonly Readonly<TaskRequest<Input>>[],
|
|
524
|
+
>(
|
|
525
|
+
taskRequests: Requests,
|
|
526
|
+
context: Context,
|
|
527
|
+
): TaskRequestOutcomes<Requests, Result, Handle> {
|
|
528
|
+
// A cancelled task request needs no key. Signals are read again after
|
|
529
|
+
// every key: `makeKey` is user code and may abort a signal.
|
|
530
|
+
const keys = taskRequests.map(({ input, signal }) =>
|
|
531
|
+
signal?.aborted ? undefined : this.#makeKey(input),
|
|
532
|
+
);
|
|
533
|
+
// A cancelled task request starts and rides nothing, and gets only its
|
|
534
|
+
// own rejection. The split happens once, here, so that a later abort
|
|
535
|
+
// cannot change which requests this call treats as cancelled.
|
|
536
|
+
const [cancelled, live] = partition(
|
|
537
|
+
taskRequests.map((request, i) => ({ request, i, key: keys[i] })),
|
|
538
|
+
({ request: { signal } }) => signal?.aborted === true,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
if (
|
|
542
|
+
live.some(({ key }) => key !== undefined && this.#schedulingKeys.has(key))
|
|
543
|
+
) {
|
|
544
|
+
throw new SchedulingContractError(
|
|
545
|
+
"schedule requested a key that it is starting a task for",
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
// What this call does with each group of requests. `indexes` are the
|
|
549
|
+
// group's positions in `taskRequests`, so results go back in request
|
|
550
|
+
// order. A START with no key always has one index, because requests with
|
|
551
|
+
// no key never share. A START with a key covers every request for that
|
|
552
|
+
// key: the first starts the task, and the rest ride it. A RIDE covers
|
|
553
|
+
// every request that rides one pending task.
|
|
554
|
+
type Decision =
|
|
555
|
+
| {
|
|
556
|
+
type: "START";
|
|
557
|
+
key: undefined;
|
|
558
|
+
start: TaskStart<Input>;
|
|
559
|
+
controller: AbortController | undefined;
|
|
560
|
+
indexes: [number];
|
|
561
|
+
}
|
|
562
|
+
| {
|
|
563
|
+
type: "START";
|
|
564
|
+
key: Key;
|
|
565
|
+
start: TaskStart<Input>;
|
|
566
|
+
controller: AbortController | undefined;
|
|
567
|
+
indexes: number[];
|
|
568
|
+
}
|
|
569
|
+
| {
|
|
570
|
+
type: "RIDE";
|
|
571
|
+
task: Task<Input, Result, Handle>;
|
|
572
|
+
indexes: number[];
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// Group. Requests with the same key share one task, so each group
|
|
576
|
+
// is one task that this call touches.
|
|
577
|
+
const requestsByKey = Map.groupBy(live, ({ key }) => key);
|
|
578
|
+
|
|
579
|
+
// Decide. Each group rides the pending task for its key, or starts
|
|
580
|
+
// a new task. The group's first request decides, as if it came alone.
|
|
581
|
+
const now = Date.now();
|
|
582
|
+
const decisions = requestsByKey
|
|
583
|
+
.entries()
|
|
584
|
+
.flatMap(([key, reqs]): Decision[] => {
|
|
585
|
+
if (key === undefined) {
|
|
586
|
+
return reqs.map(({ request: { input, signal }, i }) => ({
|
|
587
|
+
type: "START" as const,
|
|
588
|
+
key: undefined,
|
|
589
|
+
...newTaskStart(input, signal !== undefined, now, "noKey"),
|
|
590
|
+
indexes: [i],
|
|
591
|
+
}));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const ridableLookup = this.#findRidable(now, key);
|
|
595
|
+
if ("ride" in ridableLookup) {
|
|
596
|
+
return [
|
|
597
|
+
{
|
|
598
|
+
type: "RIDE" as const,
|
|
599
|
+
task: ridableLookup.ride,
|
|
600
|
+
indexes: reqs.map(({ i }) => i),
|
|
601
|
+
},
|
|
602
|
+
];
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion -- Map.groupBy never creates an empty group.
|
|
606
|
+
const { input, signal } = reqs[0]!.request;
|
|
607
|
+
return [
|
|
608
|
+
{
|
|
609
|
+
type: "START" as const,
|
|
610
|
+
key,
|
|
611
|
+
...newTaskStart(
|
|
612
|
+
input,
|
|
613
|
+
signal !== undefined,
|
|
614
|
+
now,
|
|
615
|
+
ridableLookup.startReason,
|
|
616
|
+
),
|
|
617
|
+
indexes: reqs.map(({ i }) => i),
|
|
618
|
+
},
|
|
619
|
+
];
|
|
620
|
+
})
|
|
621
|
+
// Map.groupBy puts every request with no key in one group, at the
|
|
622
|
+
// position of the first one. Sort by first request, so that `schedule`
|
|
623
|
+
// receives the started tasks in task request order.
|
|
624
|
+
.toArray()
|
|
625
|
+
.toSorted((a, b) => a.indexes[0] - b.indexes[0]);
|
|
626
|
+
|
|
627
|
+
// Schedule the new tasks.
|
|
628
|
+
const startDecisions = decisions.filter((d) => d.type === "START");
|
|
629
|
+
const taskStarts = startDecisions.map((d) => d.start);
|
|
630
|
+
|
|
631
|
+
// Every task this call touches, held as `Task.hold` describes: the
|
|
632
|
+
// ridden tasks now, and each started task when its requests are
|
|
633
|
+
// recorded. All releases wait until the end, because a release can abort
|
|
634
|
+
// a task and run user code.
|
|
635
|
+
const heldTasks = new Set(
|
|
636
|
+
decisions.filter((d) => d.type === "RIDE").map((d) => d.task),
|
|
637
|
+
);
|
|
638
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Holds each task for its side effect.
|
|
639
|
+
heldTasks.forEach((task) => task.hold());
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
// Reserve keys for the tasks we're about to schedule (to prevent
|
|
643
|
+
// infinite loop on re-entrant calls).
|
|
644
|
+
const scheduled = (() => {
|
|
645
|
+
if (taskStarts.length === 0) {
|
|
646
|
+
return [];
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const scheduledKeys = new Set(
|
|
650
|
+
startDecisions.map((d) => d.key).filter((key) => key !== undefined),
|
|
651
|
+
);
|
|
652
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Reserves each key for its side effect.
|
|
653
|
+
scheduledKeys.forEach((key) => this.#schedulingKeys.add(key));
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
return this.#schedule(taskStarts, context);
|
|
657
|
+
} finally {
|
|
658
|
+
// Free the keys before any release: a release can abort a
|
|
659
|
+
// task, and its abort listeners may request those keys, which are
|
|
660
|
+
// ridable by then.
|
|
661
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Frees each key for its side effect.
|
|
662
|
+
scheduledKeys.forEach((key) => this.#schedulingKeys.delete(key));
|
|
663
|
+
}
|
|
664
|
+
})();
|
|
665
|
+
|
|
666
|
+
if (scheduled.length !== taskStarts.length) {
|
|
667
|
+
throw new SchedulingContractError(
|
|
668
|
+
"schedule must return one scheduled task per task to start",
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Create a Task for each new task, and set up the initial bookkeeping
|
|
673
|
+
// for it. The map lets the record loop below, which walks RIDE and
|
|
674
|
+
// START decisions interleaved, find each START's new task.
|
|
675
|
+
const newTasksByStartDecision = new Map(
|
|
676
|
+
startDecisions.map((startDecision, i) => {
|
|
677
|
+
const { start, controller, key } = startDecision;
|
|
678
|
+
const task = this.#setupNewTask(
|
|
679
|
+
start,
|
|
680
|
+
controller,
|
|
681
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion -- The length check above guarantees one entry per start decision.
|
|
682
|
+
scheduled[i]!,
|
|
683
|
+
key,
|
|
684
|
+
);
|
|
685
|
+
return [start, task];
|
|
686
|
+
}),
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// Record every request on its task, and build one entry per
|
|
690
|
+
// request, back in request order. Every request in a ridden group
|
|
691
|
+
// rode; in a started group, only the first did not.
|
|
692
|
+
const result = Array<TaskRequestOutcome<Result, Handle>>(
|
|
693
|
+
taskRequests.length,
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Fills each cancelled request's result slot.
|
|
697
|
+
cancelled.forEach(({ request: { signal }, i }) => {
|
|
698
|
+
// An aborted signal's reason never changes.
|
|
699
|
+
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Rejects with the signal's abort reason, which can be any value.
|
|
700
|
+
const rejected = Promise.reject(signal?.reason);
|
|
701
|
+
// A caller may never await it.
|
|
702
|
+
// oxlint-disable-next-line no-void -- Marks the rejection handled; the caller still gets the rejected promise.
|
|
703
|
+
void rejected.catch(() => {});
|
|
704
|
+
result[i] = { result: rejected, handle: undefined, rode: false };
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Records each decision's requests for their side effects.
|
|
708
|
+
decisions.forEach((d) => {
|
|
709
|
+
switch (d.type) {
|
|
710
|
+
case "RIDE": {
|
|
711
|
+
const outcome = {
|
|
712
|
+
result: d.task.result,
|
|
713
|
+
handle: d.task.handle,
|
|
714
|
+
rode: true as const,
|
|
715
|
+
};
|
|
716
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Records each request on its task for its side effect.
|
|
717
|
+
d.indexes.forEach((reqIdx) => {
|
|
718
|
+
// NB: task is still held from before schedule()
|
|
719
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion -- Each index comes from taskRequests.
|
|
720
|
+
d.task.addRequest(taskRequests[reqIdx]!.signal, true);
|
|
721
|
+
result[reqIdx] = outcome;
|
|
722
|
+
});
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
case "START": {
|
|
726
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion -- The map holds a task for every start decision.
|
|
727
|
+
const task = newTasksByStartDecision.get(d.start)!;
|
|
728
|
+
// See `heldTasks`. Redundant when the key is undefined: that task
|
|
729
|
+
// has exactly one request, and nothing can ride it.
|
|
730
|
+
task.hold();
|
|
731
|
+
heldTasks.add(task);
|
|
732
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Records each request on its task for its side effect.
|
|
733
|
+
d.indexes.forEach((reqIdx, i) => {
|
|
734
|
+
const rode = i > 0;
|
|
735
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion -- Each index comes from taskRequests.
|
|
736
|
+
task.addRequest(taskRequests[reqIdx]!.signal, rode);
|
|
737
|
+
result[reqIdx] = {
|
|
738
|
+
result: task.result,
|
|
739
|
+
handle: task.handle,
|
|
740
|
+
rode,
|
|
741
|
+
};
|
|
742
|
+
});
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
default:
|
|
746
|
+
throw new Error(
|
|
747
|
+
`Unexpected decision: ${String(d satisfies never)}`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// The loop above fills every position of `result`, one per task
|
|
753
|
+
// request, but TS can't prove that it matches the mapped tuple type.
|
|
754
|
+
return result as TaskRequestOutcomes<Requests, Result, Handle>;
|
|
755
|
+
} finally {
|
|
756
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Releases each task for its side effect.
|
|
757
|
+
heldTasks.forEach((task) => task.release());
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Waits until every task has settled, including cancelled ones. It loops
|
|
763
|
+
* because work that settles can start new tasks, for example a producer
|
|
764
|
+
* that reads other keys. Those tasks join `#live` after the snapshot that
|
|
765
|
+
* one `allSettled` waits on.
|
|
766
|
+
*/
|
|
767
|
+
async drain(): Promise<void> {
|
|
768
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Loops until no task is live, because settling work can start new tasks.
|
|
769
|
+
while (this.#live.size > 0) {
|
|
770
|
+
// oxlint-disable-next-line typescript/promise-function-async -- Passes the existing promises; an async wrapper adds nothing.
|
|
771
|
+
await Promise.allSettled([...this.#live].map((task) => task.result));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|