@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
package/src/batcher.ts ADDED
@@ -0,0 +1,614 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { partition } from "es-toolkit";
3
+ import { defaultLoggersByComponent, type Logger } from "./logger.js";
4
+ import { PendingPromisesTracker } from "./pending-promises-tracker.js";
5
+ import { performanceNow, type PerformanceNow } from "./performance-now.js";
6
+ import { SchedulingContractError } from "./scheduling-contract-error.js";
7
+
8
+ /**
9
+ * Throws a {@link SchedulingContractError} unless a batched call returned
10
+ * exactly one result per request.
11
+ */
12
+ function assertResultCount(
13
+ results: readonly unknown[],
14
+ requestCount: number,
15
+ ): void {
16
+ if (results.length !== requestCount) {
17
+ throw new SchedulingContractError("Result count must match request count");
18
+ }
19
+ }
20
+
21
+ function asError(error: unknown): Error {
22
+ return error instanceof Error
23
+ ? error
24
+ : new Error("Batch dispatch failed", { cause: error });
25
+ }
26
+
27
+ /**
28
+ * Accumulation settings for a {@link Batcher}. An owner can expose them to its
29
+ * own users, for example as a cache's read batching options.
30
+ */
31
+ export type BatchOptions = {
32
+ /**
33
+ * The most requests one automatic dispatch takes. Defaults to 20. A direct
34
+ * (non-automatic) dispatch is never split.
35
+ */
36
+ maxBatchSize?: number;
37
+ /**
38
+ * Schedules the dispatch of each new automatic batch. It receives the
39
+ * batch's dispatch callback. Defaults to the next tick after the current
40
+ * microtasks, so requests from one event-loop turn share a batch.
41
+ */
42
+ batchScheduleFn?: (dispatch: () => void) => void;
43
+ /**
44
+ * Runs each automatic batch in the async context of the `enqueue` call
45
+ * that opened it. Off by default, because the default schedule, and a
46
+ * `batchScheduleFn` built on Node's timers or promises, already run the
47
+ * batch in that context. Turn it on when `batchScheduleFn` runs its
48
+ * callbacks from an unrelated context, or when a batch that `flush` or
49
+ * `close` sends early must keep its first request's context instead of
50
+ * the closer's. It costs one `AsyncResource` per batch.
51
+ */
52
+ preserveAsyncContext?: boolean;
53
+ };
54
+
55
+ /** One unit of work that a caller gives to {@link Batcher.enqueue}. */
56
+ export type BatchItem<Input> = {
57
+ readonly input: Input;
58
+ /**
59
+ * Cancels the item while it is queued. When it aborts, the item leaves the
60
+ * queue and its result rejects with the signal's reason. Omit it for work
61
+ * that can never be cancelled.
62
+ */
63
+ readonly signal?: AbortSignal | undefined;
64
+ };
65
+
66
+ /** One item as the batcher's `dispatch` receives it. */
67
+ export type DispatchedItem<Input> = {
68
+ readonly input: Input;
69
+ /** Milliseconds from enqueue to dispatch. */
70
+ readonly queueWaitMs: number;
71
+ };
72
+
73
+ /** Facts about one dispatch call, the same for every item in it. */
74
+ export type DispatchInfo = {
75
+ /**
76
+ * True when an automatic batch sent the items, false when `dispatchNow`
77
+ * sent them.
78
+ */
79
+ readonly autoBatched: boolean;
80
+ };
81
+
82
+ /**
83
+ * One automatic batch. Like a DataLoader batch, it gets its own scheduled
84
+ * dispatch when it is created, and it takes entries until it holds
85
+ * `maxBatchSize` queued entries or dispatches.
86
+ */
87
+ type Batch<Input, Result> = {
88
+ /**
89
+ * Every entry that joined the batch, so the batch's dispatch can find the
90
+ * ones still queued. Emptied once the batch dispatches, so that a
91
+ * long-lived handle does not keep its settled siblings alive.
92
+ */
93
+ readonly entries: Entry<Input, Result>[];
94
+ /**
95
+ * The number of `entries` whose state is still `"queued"`. Until dispatch
96
+ * empties `entries`, it equals
97
+ * `entries.filter((e) => e.state === "queued").length`: an entry joins
98
+ * `entries` only in `#makeEntryInBatch`, which counts it, and it leaves the
99
+ * queued state only through `#setEntryState`, which uncounts it.
100
+ *
101
+ * It decides whether the open batch has room, so a cancelled entry, or one
102
+ * that `dispatchNow` sent, frees its slot for a new entry. The count is
103
+ * stored rather than computed because `enqueue` checks it once per entry.
104
+ * Computing it would cost one pass over the batch per entry, which is
105
+ * quadratic for a large batch, such as one with `maxBatchSize: Infinity`.
106
+ */
107
+ queuedCount: number;
108
+ /**
109
+ * Dispatches the batch. `batchScheduleFn` receives it, and `flush` and
110
+ * `#drain` call it, so every path that dispatches the batch runs in the
111
+ * same async context. With `preserveAsyncContext`, it is bound to the
112
+ * context of the `enqueue` call that opened the batch.
113
+ */
114
+ readonly dispatch: () => void;
115
+ };
116
+
117
+ /**
118
+ * One queued unit of work. `Batcher.enqueue` returns it as an opaque handle,
119
+ * which a caller passes back to `Batcher.dispatchNow`.
120
+ */
121
+ type Entry<Input, Result> = {
122
+ readonly item: BatchItem<Input>;
123
+ /**
124
+ * The automatic batch that holds the entry. When the entry leaves the
125
+ * queued state, by cancellation or by `dispatchNow`, `#setEntryState`
126
+ * uses it to free the entry's slot in that batch.
127
+ */
128
+ readonly batch: Batch<Input, Result>;
129
+ /** `performance.now()` at enqueue, for the dispatch's queue wait. */
130
+ readonly enqueuedAt: PerformanceNow;
131
+ /**
132
+ * Makes each entry dispatch and settle at most once. An entry can be
133
+ * reached by its batch's dispatch, by `dispatchNow`, and by its signal's
134
+ * abort, in any order; each acts only on a `"queued"` entry.
135
+ */
136
+ state: "queued" | "running" | "settled";
137
+ readonly result: PromiseWithResolvers<Result>;
138
+ /**
139
+ * Removes the listener attached to the abort of the item's signal, if the
140
+ * item has a signal. Runs once, at settle.
141
+ */
142
+ cleanup: (() => void) | undefined;
143
+ };
144
+
145
+ /**
146
+ * One unit of work that `#send` sends: a queued entry, or a new item of
147
+ * `dispatchNow`. A new item never waits in a batch, so it has no entry, only
148
+ * the result that `dispatchNow` returned for it.
149
+ */
150
+ type Sendable<Input, Result> =
151
+ | { readonly kind: "entry"; readonly entry: Entry<Input, Result> }
152
+ | {
153
+ readonly kind: "immediate";
154
+ readonly input: Input;
155
+ readonly result: PromiseWithResolvers<Result>;
156
+ };
157
+
158
+ export type Outcome<Result> =
159
+ | { ok: true; value: Result }
160
+ | { ok: false; error: unknown };
161
+
162
+ /** Settles `result` with `outcome`. */
163
+ export function settleResult<Result>(
164
+ result: PromiseWithResolvers<Result>,
165
+ outcome: Outcome<Result>,
166
+ ) {
167
+ if (outcome.ok) {
168
+ result.resolve(outcome.value);
169
+ } else {
170
+ // Cancellation can reject the result before anyone awaits it.
171
+ // oxlint-disable-next-line no-void -- Marks the rejection handled; the caller still gets the rejected promise.
172
+ void result.promise.catch(() => {});
173
+ result.reject(outcome.error);
174
+ }
175
+ }
176
+
177
+ /** The handle that {@link Batcher.enqueue} returns for one entry. */
178
+ export type BatchHandle<Input, Result> = Entry<Input, Result>;
179
+
180
+ /** What {@link Batcher.enqueue} returns for one item. */
181
+ export type Enqueued<Input, Result> = {
182
+ readonly result: Promise<Result>;
183
+ /**
184
+ * The handle to pass to `dispatchNow`. Undefined only when `result` is
185
+ * already rejected: the item's signal had aborted before `enqueue`, so
186
+ * the item was never queued.
187
+ */
188
+ readonly handle: BatchHandle<Input, Result> | undefined;
189
+ };
190
+
191
+ /** The default `maxBatchSize`. */
192
+ export const DEFAULT_MAX_BATCH_SIZE = 20;
193
+
194
+ /**
195
+ * Queues work and dispatches it in batches. The batcher does not share work,
196
+ * and it knows nothing of `CollapsedTaskCreator`. To share work, compose the
197
+ * two: a creator's `schedule` enqueues one item per started task, for example
198
+ * `batcher.enqueue(toStart.map((view) => ({input: view, signal:
199
+ * view.signal})))`. Then `dispatch` receives each live view, and can read
200
+ * its `collapsedRequestCount` at dispatch time. For work that is never
201
+ * shared, such as a cache's reads from its store, use a batcher directly, so
202
+ * that each request gets its own entry.
203
+ *
204
+ * Entries wait in automatic batches, which follow DataLoader's rules. A new
205
+ * batch schedules its own dispatch with `batchScheduleFn` when it gets its
206
+ * first entry. Once it holds `maxBatchSize` queued entries, the next entry
207
+ * opens a new batch with its own scheduled dispatch. A full batch does not
208
+ * dispatch early: it waits for its own callback. A caller that wants its
209
+ * work run at once calls `dispatchNow` with its new items, and with the
210
+ * handles of queued entries it also waits for, such as entries from other
211
+ * callers' enqueues. That dispatch is never split, and its new items never
212
+ * wait in a batch.
213
+ *
214
+ * Cancellation removes a queued entry: when its item's signal aborts, the
215
+ * entry leaves the queue and its result rejects with the signal's reason.
216
+ * Dispatched work always completes.
217
+ *
218
+ * Shutdown has two steps, because an owner can still accept work after its
219
+ * shutdown starts:
220
+ *
221
+ * 1. `flush` sends every pending batch at once, and drops `batchScheduleFn`,
222
+ * so later batches use the default schedule, which always fires. An owner
223
+ * calls it when its shutdown starts, because its `batchScheduleFn` may
224
+ * stop firing then.
225
+ * 2. `close` flushes too, and resolves once the batcher is idle. Then the
226
+ * batcher is closed. An owner calls it after it stops accepting work. An
227
+ * enqueue after that is a bug in the owner, so it logs a warning, but the
228
+ * work still runs.
229
+ *
230
+ * Every entry's result settles, including on cancellation. So a caller that
231
+ * must hold a resource for an item's whole life acquires it before `enqueue`
232
+ * and releases it in a `.finally` on the item's result. The batcher has no
233
+ * hook for this.
234
+ */
235
+ export class Batcher<Input, Result> {
236
+ readonly #maxBatchSize: number;
237
+ readonly #preserveAsyncContext: boolean;
238
+ /** Set to undefined by {@link flush}. */
239
+ #batchScheduleFn: BatchOptions["batchScheduleFn"];
240
+ readonly #logger: Logger;
241
+ readonly #dispatch: (
242
+ items: readonly DispatchedItem<Input>[],
243
+ info: DispatchInfo,
244
+ ) => Promise<readonly (Result | Error)[]>;
245
+
246
+ /**
247
+ * The batch that new entries join while it has room. Undefined before the
248
+ * first enqueue, and after the open batch dispatches.
249
+ */
250
+ #openBatch: Batch<Input, Result> | undefined;
251
+ /**
252
+ * Batches whose dispatch is scheduled and has not run, so `flush` and
253
+ * `#drain` can dispatch them early.
254
+ */
255
+ readonly #pendingBatches = new Set<Batch<Input, Result>>();
256
+ /** Dispatches that have not settled, so `#drain` can wait for them. */
257
+ readonly #running = new PendingPromisesTracker();
258
+ /**
259
+ * `"closing"` from the call to `close` until the batcher is idle. Then
260
+ * `"closed"`, and `enqueue` warns.
261
+ */
262
+ #status: "open" | "closing" | "closed" = "open";
263
+ /** The promise that `close` returns, so repeated calls share it. */
264
+ #closed: Promise<void> | undefined;
265
+
266
+ constructor(
267
+ options: BatchOptions & {
268
+ /**
269
+ * Runs one call for the given items. It resolves with one result or
270
+ * Error per item, in order. A rejection fails every item in the call.
271
+ */
272
+ dispatch: (
273
+ items: readonly DispatchedItem<Input>[],
274
+ info: DispatchInfo,
275
+ ) => Promise<readonly (Result | Error)[]>;
276
+ /** Receives the warning for an enqueue after close. */
277
+ logger?: Logger;
278
+ },
279
+ ) {
280
+ const maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
281
+ if (
282
+ maxBatchSize !== Infinity &&
283
+ (!Number.isInteger(maxBatchSize) || maxBatchSize < 1)
284
+ ) {
285
+ throw new TypeError("maxBatchSize must be a positive integer");
286
+ }
287
+
288
+ this.#maxBatchSize = maxBatchSize;
289
+ this.#preserveAsyncContext = options.preserveAsyncContext ?? false;
290
+ this.#batchScheduleFn = options.batchScheduleFn;
291
+ this.#logger = options.logger ?? defaultLoggersByComponent.batcher;
292
+ this.#dispatch = options.dispatch;
293
+ }
294
+
295
+ /**
296
+ * Queues one entry per item, in order, and returns each item's result and
297
+ * handle.
298
+ *
299
+ * Each entry joins the open automatic batch, or opens a new one when that
300
+ * batch is full. An item whose signal already aborted is not queued: its
301
+ * result is rejected with the signal's reason, and it has no handle. The
302
+ * other items are not affected. After the batcher has closed, it also logs
303
+ * a warning.
304
+ */
305
+ enqueue(items: readonly BatchItem<Input>[]): Enqueued<Input, Result>[] {
306
+ if (this.#status === "closed") {
307
+ this.#logger("batcher", "warn", "enqueue called after close", {
308
+ itemCount: items.length,
309
+ });
310
+ }
311
+
312
+ // Batches that this call opens. Their dispatches are scheduled only once
313
+ // every entry is in place: a `batchScheduleFn` that runs its callback
314
+ // synchronously would otherwise dispatch a batch before its entries
315
+ // joined, and strand them in a dispatched batch.
316
+ const opened: Batch<Input, Result>[] = [];
317
+ // One call runs at one time, so its entries share it.
318
+ const enqueuedAt = performanceNow();
319
+ const enqueued = items.map((item): Enqueued<Input, Result> => {
320
+ // Like a queued entry that is cancelled, an item that is already
321
+ // cancelled is rejected, not sent.
322
+ if (item.signal?.aborted) {
323
+ const result = Promise.withResolvers<Result>();
324
+ settleResult(result, { ok: false, error: item.signal.reason });
325
+ return { result: result.promise, handle: undefined };
326
+ }
327
+
328
+ const { isNew: batchIsNew, batch } = this.#joinableBatch();
329
+ if (batchIsNew) {
330
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Collects the batches that this call opens, in order.
331
+ opened.push(batch);
332
+ }
333
+ const entry = this.#makeEntryInBatch(item, batch, enqueuedAt);
334
+ return { result: entry.result.promise, handle: entry };
335
+ });
336
+
337
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Schedules each opened batch for its side effect.
338
+ opened.forEach((batch) => this.#scheduleDispatch(batch));
339
+ return enqueued;
340
+ }
341
+
342
+ #makeEntryInBatch(
343
+ item: BatchItem<Input>,
344
+ batch: Batch<Input, Result>,
345
+ enqueuedAt: PerformanceNow,
346
+ ): Entry<Input, Result> {
347
+ const entry: Entry<Input, Result> = {
348
+ item,
349
+ batch,
350
+ enqueuedAt,
351
+ state: "queued",
352
+ result: Promise.withResolvers<Result>(),
353
+ cleanup: (() => {
354
+ const { signal } = item;
355
+ if (!signal) return undefined;
356
+
357
+ const onAbort = () => {
358
+ if (entry.state !== "queued") return;
359
+ this.#settle(entry, { ok: false, error: signal.reason });
360
+ };
361
+ signal.addEventListener("abort", onAbort, { once: true });
362
+ return () => signal.removeEventListener("abort", onAbort);
363
+ })(),
364
+ };
365
+
366
+ // Each entry joins its batch here, so that `batch.entries` and
367
+ // `batch.queuedCount` agree from the moment `entry.batch` is set. A
368
+ // separate placement pass would leave a window in which a queued entry has
369
+ // no batch.
370
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- The batch owns its entry list, and the entry joins it here.
371
+ batch.entries.push(entry);
372
+ batch.queuedCount += 1;
373
+
374
+ return entry;
375
+ }
376
+
377
+ /**
378
+ * Dispatches the still-queued entries among `handles`, plus the new
379
+ * `items`, as one call, which is never split. It skips entries that are
380
+ * running or settled. A caller can pass handles from other callers'
381
+ * enqueues, so one dispatch covers all of the work it waits for.
382
+ *
383
+ * The new items never wait in a batch, so a `batchScheduleFn` cannot send
384
+ * them early. An item whose signal already aborted is rejected and not
385
+ * sent. Returns each item's result, in order. After close, it logs a
386
+ * warning when it gets items, as `enqueue` does.
387
+ *
388
+ * It drops repeated handles, because one call can hold the same handle
389
+ * more than once: two requests of a call that share one task get the same
390
+ * handle, and the entry must be sent once.
391
+ */
392
+ dispatchNow(
393
+ handles: Iterable<BatchHandle<Input, Result>>,
394
+ items: readonly BatchItem<Input>[] = [],
395
+ ): Promise<Result>[] {
396
+ if (this.#status === "closed" && items.length > 0) {
397
+ this.#logger("batcher", "warn", "dispatchNow called after close", {
398
+ itemCount: items.length,
399
+ });
400
+ }
401
+
402
+ const results = items.map((it) => ({
403
+ result: Promise.withResolvers<Result>(),
404
+ signal: it.signal,
405
+ input: it.input,
406
+ }));
407
+
408
+ const [immediateItems, abortedItems] = partition(
409
+ results,
410
+ ({ signal }) => !signal?.aborted,
411
+ );
412
+
413
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Settles each aborted item for its side effect.
414
+ abortedItems.forEach(({ signal, result }) => {
415
+ settleResult(result, { ok: false, error: signal?.reason });
416
+ });
417
+
418
+ this.#send(
419
+ [
420
+ ...[...new Set(handles)].map((entry) => ({
421
+ kind: "entry" as const,
422
+ entry,
423
+ })),
424
+ ...immediateItems.map(({ input, result }) => ({
425
+ kind: "immediate" as const,
426
+ input,
427
+ result,
428
+ })),
429
+ ],
430
+ false,
431
+ );
432
+ // oxlint-disable-next-line typescript/promise-function-async -- Returns the existing promises; an async wrapper would add new ones.
433
+ return results.map((r) => r.result.promise);
434
+ }
435
+
436
+ /**
437
+ * Dispatches every pending batch now, and drops `batchScheduleFn`, which
438
+ * the owner may stop during shutdown. Later batches use the default
439
+ * schedule. The batcher stays open: later enqueues do not warn.
440
+ */
441
+ flush(): void {
442
+ this.#batchScheduleFn = undefined;
443
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Dispatches each pending batch for its side effect.
444
+ this.#pendingBatches.forEach((batch) => batch.dispatch());
445
+ }
446
+
447
+ /**
448
+ * Flushes, then resolves when the batcher is idle. Then the batcher is
449
+ * closed, and later enqueues log a warning. Repeated calls return the same
450
+ * promise.
451
+ */
452
+ // oxlint-disable-next-line typescript/promise-function-async -- Repeated calls must return the same promise.
453
+ close(): Promise<void> {
454
+ if (this.#closed) return this.#closed;
455
+
456
+ this.#status = "closing";
457
+ this.flush();
458
+ this.#closed = this.#drain().then(() => {
459
+ this.#status = "closed";
460
+ });
461
+ return this.#closed;
462
+ }
463
+
464
+ /**
465
+ * Waits until no batch is pending and no dispatch is running. It loops
466
+ * because a dispatch can enqueue more work into this batcher before it
467
+ * settles, for example a producer that requests other keys from its own
468
+ * wrapper.
469
+ */
470
+ async #drain(): Promise<void> {
471
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Loops until idle, because a dispatch can enqueue more work.
472
+ while (this.#running.size > 0 || this.#pendingBatches.size > 0) {
473
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Dispatches each pending batch for its side effect.
474
+ this.#pendingBatches.forEach((batch) => batch.dispatch());
475
+ await this.#running.drain();
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Returns the open batch when it has room, as DataLoader's
481
+ * `getCurrentBatch` does. Otherwise it opens a new batch, and reports it as
482
+ * new: the caller schedules a new batch's dispatch.
483
+ */
484
+ #joinableBatch() {
485
+ const open = this.#openBatch;
486
+ if (open && open.queuedCount < this.#maxBatchSize) {
487
+ return { batch: open, isNew: false };
488
+ }
489
+
490
+ const dispatch = () => this.#dispatchBatch(batch);
491
+ const batch: Batch<Input, Result> = {
492
+ entries: [],
493
+ queuedCount: 0,
494
+ dispatch: this.#preserveAsyncContext
495
+ ? AsyncLocalStorage.bind(dispatch)
496
+ : dispatch,
497
+ };
498
+ this.#openBatch = batch;
499
+ this.#pendingBatches.add(batch);
500
+ return { batch, isNew: true };
501
+ }
502
+
503
+ /** Schedules a batch's dispatch with `batchScheduleFn`, or the default. */
504
+ #scheduleDispatch(batch: Batch<Input, Result>) {
505
+ const { dispatch } = batch;
506
+ if (this.#batchScheduleFn) this.#batchScheduleFn(dispatch);
507
+ // oxlint-disable-next-line no-void -- Schedules the dispatch; nothing awaits it.
508
+ else void Promise.resolve().then(() => process.nextTick(dispatch));
509
+ }
510
+
511
+ /**
512
+ * The only code that changes `entry.state` after `enqueue` creates the
513
+ * entry. Keep it that way: it keeps `Batch.queuedCount` correct. When the
514
+ * entry leaves the queued state, it frees the entry's slot in its batch.
515
+ * From any other state, it only sets the new state.
516
+ */
517
+ #setEntryState(entry: Entry<Input, Result>, state: "running" | "settled") {
518
+ if (entry.state === "queued") entry.batch.queuedCount -= 1;
519
+ entry.state = state;
520
+ }
521
+
522
+ /**
523
+ * Marks an entry settled, runs its cleanup, and settles its result with
524
+ * `outcome`. The outcome is explicit because an error can be any value,
525
+ * such as an abort reason that is a string.
526
+ */
527
+ #settle(entry: Entry<Input, Result>, outcome: Outcome<Result>) {
528
+ this.#setEntryState(entry, "settled");
529
+ entry.cleanup?.();
530
+ entry.cleanup = undefined;
531
+ settleResult(entry.result, outcome);
532
+ }
533
+
534
+ /**
535
+ * Sends a batch's still-queued entries, once. A batch leaves
536
+ * `#pendingBatches` only here, so membership marks it undispatched.
537
+ */
538
+ #dispatchBatch(batch: Batch<Input, Result>) {
539
+ if (!this.#pendingBatches.delete(batch)) return;
540
+ if (this.#openBatch === batch) this.#openBatch = undefined;
541
+ this.#send(
542
+ batch.entries.map((entry) => ({ kind: "entry", entry })),
543
+ true,
544
+ );
545
+ batch.entries.length = 0;
546
+ }
547
+
548
+ /**
549
+ * Sends `selected` as one call, in the current async context. It skips
550
+ * entries that are no longer queued, and sends every immediate item.
551
+ * `autoBatched` says whether an automatic batch sends them.
552
+ */
553
+ #send(selected: readonly Sendable<Input, Result>[], autoBatched: boolean) {
554
+ const toSend = selected.filter(
555
+ (it) => it.kind === "immediate" || it.entry.state === "queued",
556
+ );
557
+ if (toSend.length === 0) return;
558
+
559
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Settles each sent item for its side effect.
560
+ toSend.forEach((it) => {
561
+ if (it.kind === "entry") this.#setEntryState(it.entry, "running");
562
+ });
563
+
564
+ const settle = (it: Sendable<Input, Result>, outcome: Outcome<Result>) => {
565
+ if (it.kind === "entry") this.#settle(it.entry, outcome);
566
+ else settleResult(it.result, outcome);
567
+ };
568
+
569
+ const now = performanceNow();
570
+ // oxlint-disable-next-line typescript/promise-function-async -- Promise.try adopts this promise; an async wrapper would delay settling by more microtasks.
571
+ const run = () =>
572
+ this.#dispatch(
573
+ toSend.map((it) =>
574
+ it.kind === "entry"
575
+ ? {
576
+ input: it.entry.item.input,
577
+ queueWaitMs: now - it.entry.enqueuedAt,
578
+ }
579
+ : { input: it.input, queueWaitMs: 0 },
580
+ ),
581
+ { autoBatched },
582
+ );
583
+ // Track the operation before `dispatch` runs. A `dispatch` that calls
584
+ // `close` synchronously must find this operation in `#running`, or `close`
585
+ // could resolve before the dispatch settles.
586
+ // oxlint-disable-next-line no-void -- The tracker keeps the operation for close; nothing else awaits it.
587
+ void this.#running.run(
588
+ // oxlint-disable-next-line typescript/promise-function-async -- Promise.try adopts the dispatch promise; an async wrapper would add microtasks.
589
+ () =>
590
+ Promise.try(run)
591
+ .then((results) => {
592
+ assertResultCount(results, toSend.length);
593
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Settles each sent item for its side effect.
594
+ toSend.forEach((it, index) => {
595
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- assertResultCount checked the length. A runtime check would be wrong, because Result can include undefined.
596
+ const result = results[index]!;
597
+ settle(
598
+ it,
599
+ result instanceof Error
600
+ ? { ok: false, error: result }
601
+ : { ok: true, value: result },
602
+ );
603
+ });
604
+ })
605
+ .catch((error: unknown) => {
606
+ const failure = asError(error);
607
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Settles each sent item for its side effect.
608
+ toSend.forEach((it) => {
609
+ settle(it, { ok: false, error: failure });
610
+ });
611
+ }),
612
+ );
613
+ }
614
+ }