@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.
Files changed (42) hide show
  1. package/README.md +184 -5
  2. package/dist/batcher.d.ts +231 -0
  3. package/dist/batcher.d.ts.map +1 -0
  4. package/dist/batcher.js +391 -0
  5. package/dist/batcher.js.map +1 -0
  6. package/dist/collapsed-task-creator.d.ts +235 -0
  7. package/dist/collapsed-task-creator.d.ts.map +1 -0
  8. package/dist/collapsed-task-creator.js +491 -0
  9. package/dist/collapsed-task-creator.js.map +1 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/logger.d.ts +16 -0
  15. package/dist/logger.d.ts.map +1 -0
  16. package/dist/logger.js +14 -0
  17. package/dist/logger.js.map +1 -0
  18. package/dist/pending-promises-tracker.d.ts +32 -0
  19. package/dist/pending-promises-tracker.d.ts.map +1 -0
  20. package/dist/pending-promises-tracker.js +60 -0
  21. package/dist/pending-promises-tracker.js.map +1 -0
  22. package/dist/performance-now.d.ts +14 -0
  23. package/dist/performance-now.d.ts.map +1 -0
  24. package/dist/performance-now.js +10 -0
  25. package/dist/performance-now.js.map +1 -0
  26. package/dist/scheduling-contract-error.d.ts +9 -0
  27. package/dist/scheduling-contract-error.d.ts.map +1 -0
  28. package/dist/scheduling-contract-error.js +9 -0
  29. package/dist/scheduling-contract-error.js.map +1 -0
  30. package/dist/wrap-with-task-collapsing.d.ts +30 -0
  31. package/dist/wrap-with-task-collapsing.d.ts.map +1 -0
  32. package/dist/wrap-with-task-collapsing.js +80 -0
  33. package/dist/wrap-with-task-collapsing.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/batcher.ts +614 -0
  36. package/src/collapsed-task-creator.ts +774 -0
  37. package/src/index.ts +22 -0
  38. package/src/logger.ts +32 -0
  39. package/src/pending-promises-tracker.ts +62 -0
  40. package/src/performance-now.ts +21 -0
  41. package/src/scheduling-contract-error.ts +8 -0
  42. 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
+ }