@zingage/scheduling 1.0.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 +190 -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 +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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/synchronous-result.d.ts +10 -0
- package/dist/synchronous-result.d.ts.map +1 -0
- package/dist/synchronous-result.js +18 -0
- package/dist/synchronous-result.js.map +1 -0
- package/dist/time-sliced-search.d.ts +47 -0
- package/dist/time-sliced-search.d.ts.map +1 -0
- package/dist/time-sliced-search.js +41 -0
- package/dist/time-sliced-search.js.map +1 -0
- package/dist/time-slicing.d.ts +20 -7
- package/dist/time-slicing.d.ts.map +1 -1
- package/dist/time-slicing.js +14 -8
- package/dist/time-slicing.js.map +1 -1
- 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 +23 -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/synchronous-result.ts +28 -0
- package/src/time-sliced-search.ts +98 -0
- package/src/time-slicing.ts +50 -26
- package/src/wrap-with-task-collapsing.ts +103 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zingage/scheduling",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/joinava/sensitive-packages.git",
|
|
@@ -24,16 +24,22 @@
|
|
|
24
24
|
"access": "public",
|
|
25
25
|
"registry": "https://registry.npmjs.org/"
|
|
26
26
|
},
|
|
27
|
-
"dependencies": {
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"debug": "^4.4.1",
|
|
29
|
+
"es-toolkit": "1.47.1",
|
|
30
|
+
"type-fest": "5.8.0",
|
|
31
|
+
"type-party": "0.10.2"
|
|
32
|
+
},
|
|
28
33
|
"devDependencies": {
|
|
34
|
+
"@types/debug": "^4.1.12",
|
|
29
35
|
"@types/node": "^26.1.1",
|
|
30
36
|
"fast-check": "^4.2.0",
|
|
31
37
|
"oxlint": "^1.83.0",
|
|
32
38
|
"oxlint-tsgolint": "^7.0.2001",
|
|
33
39
|
"typescript": "npm:@typescript/typescript6@^6.0.0",
|
|
34
40
|
"typescript-7": "npm:typescript@^7.0.2",
|
|
35
|
-
"@zingage/base-configs-
|
|
36
|
-
"@zingage/base-configs-
|
|
41
|
+
"@zingage/base-configs-ts": "1.0.0",
|
|
42
|
+
"@zingage/base-configs-oxlint": "1.0.0"
|
|
37
43
|
},
|
|
38
44
|
"scripts": {
|
|
39
45
|
"tsc:watch": "tsc -b --watch",
|
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
|
+
}
|