@zingage/scheduling 0.0.0-stage → 1.1.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.
@@ -0,0 +1,490 @@
1
+ import assert from "node:assert/strict";
2
+ import { setImmediate } from "node:timers/promises";
3
+ import {
4
+ GENERATOR_HINT,
5
+ TRAVERSAL_HINT,
6
+ assertNotThenable,
7
+ } from "./synchronous-result.js";
8
+
9
+ const DEFAULT_TIME_SLICE_MS = 4;
10
+ const performanceNow = performance.now.bind(performance);
11
+
12
+ /**
13
+ * Tracks measured synchronous work before yielding to the event loop.
14
+ *
15
+ * A new budget allows 4 ms by default. Related operations can share an instance.
16
+ * Promise waits and unmeasured work do not consume it. One synchronous segment
17
+ * cannot be interrupted. Nested measurements on the same budget count once.
18
+ *
19
+ * @example Share the remaining allowance across related operations.
20
+ * const budget = new TimeSliceBudget();
21
+ * const doubled = await timeSlicedMap([1, 2], (value) => value * 2, budget);
22
+ * await timeSlicedForEach(doubled, (value) => console.log(value), budget);
23
+ */
24
+ export class TimeSliceBudget {
25
+ readonly #sliceMs;
26
+ readonly #now;
27
+ readonly #yieldToEventLoop;
28
+ #remainingMs;
29
+ #depth = 0;
30
+
31
+ /**
32
+ * Creates a budget with a positive, finite allowance in milliseconds.
33
+ *
34
+ * `now` must be monotonic and return milliseconds. The default uses
35
+ * `performance.now`. The default scheduler uses promise-based `setImmediate`.
36
+ * A custom scheduler must let the event loop advance; a resolved promise does not.
37
+ *
38
+ * @example Use a smaller allowance for one operation.
39
+ * const budget = new TimeSliceBudget(2);
40
+ */
41
+ constructor(
42
+ sliceMs = DEFAULT_TIME_SLICE_MS,
43
+ now = performanceNow,
44
+ yieldToEventLoop: () => Promise<void> = setImmediate,
45
+ ) {
46
+ assert.ok(
47
+ Number.isFinite(sliceMs) && sliceMs > 0,
48
+ "sliceMs must be finite and greater than zero",
49
+ );
50
+ this.#sliceMs = sliceMs;
51
+ this.#remainingMs = sliceMs;
52
+ this.#now = now.bind(performance);
53
+ this.#yieldToEventLoop = yieldToEventLoop;
54
+ }
55
+
56
+ /**
57
+ * Runs synchronous work and charges its elapsed time, including when it throws.
58
+ *
59
+ * Nested measurements on this budget count once. This method does not yield.
60
+ * Call `yieldIfNeeded` at a safe boundary after handling the result.
61
+ * `work` must be synchronous. If it returns a promise or other thenable, this
62
+ * method throws a `TypeError` after charging the elapsed time.
63
+ *
64
+ * @example Measure one segment and then check the budget.
65
+ * const budget = new TimeSliceBudget();
66
+ * const value = budget.measure(() => 2 * 3);
67
+ * const pending = budget.yieldIfNeeded();
68
+ * if (pending !== undefined) await pending;
69
+ */
70
+ measure<Result>(work: () => Result): Result {
71
+ const outermost = this.#depth === 0;
72
+ const startedAt = outermost ? this.#now() : 0;
73
+ this.#depth += 1;
74
+ try {
75
+ return assertNotThenable(
76
+ work(),
77
+ "TimeSliceBudget.measure",
78
+ GENERATOR_HINT,
79
+ );
80
+ } finally {
81
+ this.#depth -= 1;
82
+ if (outermost) this.#remainingMs -= this.#now() - startedAt;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Returns a yield promise if the allowance is exhausted, or `undefined` otherwise.
88
+ *
89
+ * Replenishes before scheduling the yield. Connected work can use the next
90
+ * allowance while this caller waits. Resuming does not replenish it again.
91
+ *
92
+ * @example Avoid a microtask suspension when the budget still has time.
93
+ * const budget = new TimeSliceBudget();
94
+ * const pending = budget.yieldIfNeeded();
95
+ * if (pending !== undefined) await pending;
96
+ */
97
+ yieldIfNeeded(): Promise<void> | undefined {
98
+ if (this.#remainingMs > 0) return undefined;
99
+ this.#remainingMs = this.#sliceMs;
100
+ return this.#yieldToEventLoop();
101
+ }
102
+ }
103
+
104
+ type CallbackGenerator<Result> = Generator<
105
+ PromiseLike<unknown>,
106
+ Result,
107
+ unknown
108
+ >;
109
+
110
+ /**
111
+ * Suspends a generator callback until a promise settles, preserving its value type.
112
+ *
113
+ * Use `yield* waitFor(promise)` inside a generator passed to `timeSlicedMap` or
114
+ * `timeSlicedForEach`. Rejections enter the generator through `throw()`, so its
115
+ * `catch` and `finally` blocks run normally. Falsy and void results are supported.
116
+ *
117
+ * @example Infer the resolved value without annotating the generator.
118
+ * const lengths = await timeSlicedMap(["Ada"], function* (name) {
119
+ * const text = yield* waitFor(Promise.resolve(name));
120
+ * return text.length;
121
+ * });
122
+ *
123
+ * @example Recover from a rejected promise inside the generator.
124
+ * const values = await timeSlicedMap([1], function* (value) {
125
+ * try {
126
+ * return yield* waitFor(Promise.reject<number>(new TypeError("unavailable")));
127
+ * } catch {
128
+ * return value;
129
+ * }
130
+ * });
131
+ */
132
+ export function* waitFor<Value>(
133
+ promise: PromiseLike<Value>,
134
+ ): CallbackGenerator<Value> {
135
+ const result: { value?: { resolved: Value } } = {};
136
+ yield promise.then((value) => {
137
+ result.value = { resolved: value };
138
+ });
139
+ assert.ok(result.value, "waitFor must resume after its promise resolves");
140
+ return result.value.resolved;
141
+ }
142
+
143
+ function isCallbackGenerator<Result>(
144
+ value: Result | CallbackGenerator<Result>,
145
+ ): value is CallbackGenerator<Result> {
146
+ return (
147
+ typeof value === "object" &&
148
+ value !== null &&
149
+ "next" in value &&
150
+ typeof value.next === "function" &&
151
+ "throw" in value &&
152
+ typeof value.throw === "function" &&
153
+ Symbol.iterator in value &&
154
+ typeof value[Symbol.iterator] === "function"
155
+ );
156
+ }
157
+
158
+ async function closeCallbackGenerator(
159
+ generator: CallbackGenerator<unknown>,
160
+ budget: TimeSliceBudget,
161
+ ): Promise<void> {
162
+ const state = { step: budget.measure(() => generator.return(undefined)) };
163
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Drain yielded cleanup without calling the failed scheduler again.
164
+ while (!state.step.done) {
165
+ const outcome = await Promise.resolve(state.step.value).then(
166
+ (value) => ({ status: "fulfilled" as const, value }),
167
+ (error: unknown) => ({ status: "rejected" as const, error }),
168
+ );
169
+ state.step =
170
+ outcome.status === "fulfilled"
171
+ ? budget.measure(() => generator.next(outcome.value))
172
+ : budget.measure(() => generator.throw(outcome.error));
173
+ }
174
+ }
175
+
176
+ async function runCallbackGenerator<Result>(
177
+ generator: CallbackGenerator<Result>,
178
+ budget: TimeSliceBudget,
179
+ ): Promise<Result> {
180
+ const state = { step: budget.measure(() => generator.next()) };
181
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Each generator segment must settle before the next measured segment starts.
182
+ while (!state.step.done) {
183
+ // Attach rejection handling before a budget yield can let the event loop advance.
184
+ const settled = Promise.resolve(state.step.value).then(
185
+ (value) => ({ status: "fulfilled" as const, value }),
186
+ (error: unknown) => ({ status: "rejected" as const, error }),
187
+ );
188
+ try {
189
+ const pending = budget.yieldIfNeeded();
190
+ if (pending !== undefined) await pending;
191
+ } catch (error) {
192
+ try {
193
+ await closeCallbackGenerator(generator, budget);
194
+ } catch {
195
+ // Cleanup failures must not replace the scheduler failure.
196
+ }
197
+ throw error;
198
+ }
199
+ const outcome = await settled;
200
+ state.step =
201
+ outcome.status === "fulfilled"
202
+ ? budget.measure(() => generator.next(outcome.value))
203
+ : budget.measure(() => generator.throw(outcome.error));
204
+ }
205
+ return state.step.value;
206
+ }
207
+
208
+ async function traverse<Item, Result>(
209
+ helper: string,
210
+ items: Iterable<Item>,
211
+ callback: (item: Item, index: number) => Result | CallbackGenerator<Result>,
212
+ accept: (result: Result) => void,
213
+ budget: TimeSliceBudget,
214
+ ): Promise<void> {
215
+ const position = { index: 0 };
216
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Native iteration owns cleanup while callbacks and budget yields settle sequentially.
217
+ for (const item of items) {
218
+ const value = budget.measure(() =>
219
+ assertNotThenable(callback(item, position.index), helper, GENERATOR_HINT),
220
+ );
221
+ const result = isCallbackGenerator<Result>(value)
222
+ ? await runCallbackGenerator(value, budget)
223
+ : value;
224
+ budget.measure(() => accept(result));
225
+ position.index += 1;
226
+ const pending = budget.yieldIfNeeded();
227
+ if (pending !== undefined) await pending;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Runs callbacks sequentially in input order and discards their return values.
233
+ *
234
+ * Accepts synchronous callbacks or generators that yield promises. Each generator
235
+ * finishes before the next item starts. Measures synchronous callback work and
236
+ * every generator `next()`/`throw()` segment, excluding promise waits.
237
+ * Iterator advancement is unmeasured. Unhandled failures stop the traversal.
238
+ * A non-generator callback must be synchronous. If it returns a promise or
239
+ * other thenable (an `async` function, for example), the traversal throws a
240
+ * `TypeError` on that first call. The type checker does not catch this.
241
+ * The optional budget defaults to a new independent `TimeSliceBudget`.
242
+ *
243
+ * @example Run a synchronous visitor.
244
+ * await timeSlicedForEach([1, 2], (value) => console.log(value));
245
+ *
246
+ * @example Await work sequentially with a typed generator callback.
247
+ * await timeSlicedForEach([1, 2], function* (value) {
248
+ * const doubled = yield* waitFor(Promise.resolve(value * 2));
249
+ * console.log(doubled);
250
+ * });
251
+ */
252
+ export function timeSlicedForEach<Item, Result>(
253
+ items: Iterable<Item>,
254
+ callback: (item: Item, index: number) => CallbackGenerator<Result>,
255
+ budget?: TimeSliceBudget,
256
+ ): Promise<void>;
257
+ /**
258
+ * Runs a synchronous visitor in input order with an optional processing budget.
259
+ *
260
+ * Throws a `TypeError` if the visitor returns a promise or other thenable.
261
+ *
262
+ * @example
263
+ * await timeSlicedForEach([1, 2], (value) => console.log(value));
264
+ */
265
+ export function timeSlicedForEach<Item, Result>(
266
+ items: Iterable<Item>,
267
+ callback: (item: Item, index: number) => Result,
268
+ budget?: TimeSliceBudget,
269
+ ): Promise<void>;
270
+ export async function timeSlicedForEach<Item, Result>(
271
+ items: Iterable<Item>,
272
+ callback: (item: Item, index: number) => Result | CallbackGenerator<Result>,
273
+ budget = new TimeSliceBudget(),
274
+ ): Promise<void> {
275
+ await traverse("timeSlicedForEach", items, callback, () => {}, budget);
276
+ }
277
+
278
+ /**
279
+ * Returns transformed values in input order using sequential callbacks.
280
+ *
281
+ * Accepts synchronous callbacks or generators that yield promises. Each generator
282
+ * finishes before the next item starts. Measures every synchronous segment,
283
+ * including `throw()` recovery and result accumulation. Promise waits and iterator
284
+ * advancement are unmeasured. Ordinary async callbacks are not supported: if a
285
+ * non-generator callback returns a promise or other thenable, the traversal
286
+ * throws a `TypeError` on that first call. The type checker does not catch this.
287
+ * The optional budget defaults to a new independent `TimeSliceBudget`.
288
+ *
289
+ * @example Transform synchronous values.
290
+ * const doubled = await timeSlicedMap([1, 2], (value) => value * 2);
291
+ *
292
+ * @example Resume measured work after each promise settles.
293
+ * const doubled = await timeSlicedMap([1, 2], function* (value) {
294
+ * const factor = yield* waitFor(Promise.resolve(2));
295
+ * return value * factor;
296
+ * });
297
+ */
298
+ export function timeSlicedMap<Item, Result>(
299
+ items: Iterable<Item>,
300
+ callback: (item: Item, index: number) => CallbackGenerator<Result>,
301
+ budget?: TimeSliceBudget,
302
+ ): Promise<Result[]>;
303
+ /**
304
+ * Maps synchronous values in input order with an optional processing budget.
305
+ *
306
+ * Throws a `TypeError` if the callback returns a promise or other thenable.
307
+ *
308
+ * @example
309
+ * const doubled = await timeSlicedMap([1, 2], (value) => value * 2);
310
+ */
311
+ export function timeSlicedMap<Item, Result>(
312
+ items: Iterable<Item>,
313
+ callback: (item: Item, index: number) => Result,
314
+ budget?: TimeSliceBudget,
315
+ ): Promise<Result[]>;
316
+ export async function timeSlicedMap<Item, Result>(
317
+ items: Iterable<Item>,
318
+ callback: (item: Item, index: number) => Result | CallbackGenerator<Result>,
319
+ budget = new TimeSliceBudget(),
320
+ ): Promise<Result[]> {
321
+ const results: Result[] = [];
322
+ await traverse(
323
+ "timeSlicedMap",
324
+ items,
325
+ callback,
326
+ (result) => {
327
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Append to the local result to avoid quadratic copying.
328
+ results.push(result);
329
+ },
330
+ budget,
331
+ );
332
+ return results;
333
+ }
334
+
335
+ /**
336
+ * Maps each item to a synchronous list and flattens the lists in input order.
337
+ *
338
+ * Measures callbacks and appends. It can yield within one large returned list.
339
+ * One callback cannot be interrupted. For asynchronous mapping, first use a
340
+ * generator callback with `timeSlicedMap`, then flatten the returned lists.
341
+ * The optional budget defaults to a new independent `TimeSliceBudget`.
342
+ *
343
+ * @example Flatten synchronous results.
344
+ * const values = await timeSlicedFlatMap([1, 2], (value) => [value, value * 2]);
345
+ *
346
+ * @example Fetch lists with generators and share their budget with flattening.
347
+ * const budget = new TimeSliceBudget();
348
+ * const pages = await timeSlicedMap([1, 2], function* (value) {
349
+ * return yield* waitFor(Promise.resolve([value, value * 2]));
350
+ * }, budget);
351
+ * const values = await timeSlicedFlatMap(pages, (page) => page, budget);
352
+ */
353
+ export async function timeSlicedFlatMap<Item, Result>(
354
+ items: Iterable<Item>,
355
+ callback: (item: Item, index: number) => readonly Result[],
356
+ budget = new TimeSliceBudget(),
357
+ ): Promise<Result[]> {
358
+ const results: Result[] = [];
359
+ const position = { index: 0 };
360
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Native outer iteration owns cleanup; inner traversal can also yield.
361
+ for (const item of items) {
362
+ const mapped = budget.measure(() => callback(item, position.index));
363
+ position.index += 1;
364
+ const pending = budget.yieldIfNeeded();
365
+ if (pending !== undefined) await pending;
366
+ await timeSlicedForEach(
367
+ mapped,
368
+ (value) => {
369
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Append incrementally without argument-spread limits or quadratic copying.
370
+ results.push(value);
371
+ },
372
+ budget,
373
+ );
374
+ }
375
+ const pending = budget.yieldIfNeeded();
376
+ if (pending !== undefined) await pending;
377
+ return results;
378
+ }
379
+
380
+ /**
381
+ * Returns items whose synchronous predicate is true, preserving input order.
382
+ *
383
+ * Measures predicate work and result accumulation. One predicate call cannot be
384
+ * interrupted. Throws a `TypeError` if the predicate returns a promise or other
385
+ * thenable. The optional budget defaults to a new independent instance.
386
+ *
387
+ * @example Keep odd numbers.
388
+ * const odd = await timeSlicedFilter([1, 2, 3], (value) => value % 2 === 1);
389
+ *
390
+ * @example Compose filtering inside a generator with a shared budget.
391
+ * const budget = new TimeSliceBudget();
392
+ * const groups = await timeSlicedMap([[1, 2, 3]], function* (values) {
393
+ * return yield* waitFor(timeSlicedFilter(values, (value) => value > 1, budget));
394
+ * }, budget);
395
+ */
396
+ export async function timeSlicedFilter<Item>(
397
+ items: Iterable<Item>,
398
+ predicate: (item: Item, index: number) => boolean,
399
+ budget = new TimeSliceBudget(),
400
+ ): Promise<Item[]> {
401
+ const results: Item[] = [];
402
+ await timeSlicedForEach(
403
+ items,
404
+ (item, index) => {
405
+ if (
406
+ assertNotThenable(
407
+ predicate(item, index),
408
+ "timeSlicedFilter",
409
+ TRAVERSAL_HINT,
410
+ )
411
+ ) {
412
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Append to the local result without quadratic copying.
413
+ results.push(item);
414
+ }
415
+ },
416
+ budget,
417
+ );
418
+ return results;
419
+ }
420
+
421
+ /**
422
+ * Groups items by a synchronous key, preserving key and group insertion order.
423
+ *
424
+ * Uses `Map` key equality. Measures key selection and group accumulation.
425
+ * `keyOf` must be synchronous. If it returns a promise or other thenable, the
426
+ * operation throws a `TypeError` before it stores the key.
427
+ * The optional budget defaults to a new independent `TimeSliceBudget`.
428
+ *
429
+ * @example Group strings by length.
430
+ * const groups = await timeSlicedGroupBy(["a", "bb", "c"], (text) => text.length);
431
+ *
432
+ * @example Await grouping inside a generator and share its budget.
433
+ * const budget = new TimeSliceBudget();
434
+ * const groupedPages = await timeSlicedMap([[1, 2, 3]], function* (values) {
435
+ * return yield* waitFor(timeSlicedGroupBy(values, (value) => value % 2, budget));
436
+ * }, budget);
437
+ */
438
+ export async function timeSlicedGroupBy<Item, Key>(
439
+ items: Iterable<Item>,
440
+ keyOf: (item: Item, index: number) => Key,
441
+ budget = new TimeSliceBudget(),
442
+ ): Promise<Map<Key, Item[]>> {
443
+ const groups = new Map<Key, Item[]>();
444
+ await timeSlicedForEach(
445
+ items,
446
+ (item, index) => {
447
+ const key = assertNotThenable(
448
+ keyOf(item, index),
449
+ "timeSlicedGroupBy",
450
+ TRAVERSAL_HINT,
451
+ );
452
+ const group = groups.get(key);
453
+ if (group === undefined) groups.set(key, [item]);
454
+ // oxlint-disable-next-line zingage/no-restricted-syntax -- Each local group accumulates without quadratic copying.
455
+ else group.push(item);
456
+ },
457
+ budget,
458
+ );
459
+ return groups;
460
+ }
461
+
462
+ /**
463
+ * Builds a Map from entries in input order; the last value for a repeated key wins.
464
+ *
465
+ * Measures entry insertion. Replacing a value preserves the key's first insertion
466
+ * position. The optional budget defaults to a new independent `TimeSliceBudget`.
467
+ *
468
+ * @example Build a Map while preserving last-value-wins behavior.
469
+ * const map = await timeSlicedMapFromEntries([["a", 1], ["a", 2]] as const);
470
+ *
471
+ * @example Await Map construction inside a generator with a shared budget.
472
+ * const budget = new TimeSliceBudget();
473
+ * const maps = await timeSlicedMap([1, 2], function* (value) {
474
+ * return yield* waitFor(timeSlicedMapFromEntries([["key", value]] as const, budget));
475
+ * }, budget);
476
+ */
477
+ export async function timeSlicedMapFromEntries<Key, Value>(
478
+ entries: Iterable<readonly [Key, Value]>,
479
+ budget = new TimeSliceBudget(),
480
+ ): Promise<Map<Key, Value>> {
481
+ const result = new Map<Key, Value>();
482
+ await timeSlicedForEach(
483
+ entries,
484
+ ([key, value]) => {
485
+ result.set(key, value);
486
+ },
487
+ budget,
488
+ );
489
+ return result;
490
+ }