@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/src/index.ts
CHANGED
|
@@ -9,3 +9,26 @@ export {
|
|
|
9
9
|
TimeSliceBudget,
|
|
10
10
|
} from "./time-slicing.js";
|
|
11
11
|
export { timeSlicedSort } from "./time-sliced-sort.js";
|
|
12
|
+
export { timeSlicedFind, timeSlicedSome } from "./time-sliced-search.js";
|
|
13
|
+
export {
|
|
14
|
+
CollapsedTaskCreator,
|
|
15
|
+
type ScheduleTasks,
|
|
16
|
+
type SharedTaskView,
|
|
17
|
+
type TaskRequestOutcome,
|
|
18
|
+
type TaskRequestOutcomes,
|
|
19
|
+
type TaskStartReason,
|
|
20
|
+
} from "./collapsed-task-creator.js";
|
|
21
|
+
export { wrapWithTaskCollapsing } from "./wrap-with-task-collapsing.js";
|
|
22
|
+
export {
|
|
23
|
+
Batcher,
|
|
24
|
+
DEFAULT_MAX_BATCH_SIZE,
|
|
25
|
+
type BatchHandle,
|
|
26
|
+
type BatchItem,
|
|
27
|
+
type BatchOptions,
|
|
28
|
+
type DispatchedItem,
|
|
29
|
+
type DispatchInfo,
|
|
30
|
+
type Enqueued,
|
|
31
|
+
} from "./batcher.js";
|
|
32
|
+
export { PendingPromisesTracker } from "./pending-promises-tracker.js";
|
|
33
|
+
export { SchedulingContractError } from "./scheduling-contract-error.js";
|
|
34
|
+
export type { Logger, SchedulingComponentName } from "./logger.js";
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { default as debug } from "debug";
|
|
2
|
+
|
|
3
|
+
/** The components in this package that write log lines. */
|
|
4
|
+
export type SchedulingComponentName = "batcher" | "collapsed-task-creator";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The signature of a custom log function. `Batcher` and
|
|
8
|
+
* `wrapWithTaskCollapsing` accept one, and call it when they log a message.
|
|
9
|
+
*
|
|
10
|
+
* A logger that accepts more component names, such as a logger shared with
|
|
11
|
+
* other packages, is also a valid `Logger`.
|
|
12
|
+
*/
|
|
13
|
+
export type Logger = (
|
|
14
|
+
component: SchedulingComponentName,
|
|
15
|
+
level: "trace" | "debug" | "info" | "warn" | "error" | "fatal",
|
|
16
|
+
message: string,
|
|
17
|
+
data?: unknown,
|
|
18
|
+
) => void;
|
|
19
|
+
|
|
20
|
+
/** A logger that writes to the `debug` namespace `@zingage/scheduling:<name>`. */
|
|
21
|
+
function debugLogger(name: SchedulingComponentName): Logger {
|
|
22
|
+
const write = debug(`@zingage/scheduling:${name}`);
|
|
23
|
+
return (_, level, message, data) => {
|
|
24
|
+
write(`(${level}) ${message} %O`, data);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The default logger of each component. */
|
|
29
|
+
export const defaultLoggersByComponent = {
|
|
30
|
+
batcher: debugLogger("batcher"),
|
|
31
|
+
"collapsed-task-creator": debugLogger("collapsed-task-creator"),
|
|
32
|
+
} satisfies Record<SchedulingComponentName, Logger>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks promises until they settle, so that an owner's `close` can wait for
|
|
3
|
+
* the work it accepted.
|
|
4
|
+
*
|
|
5
|
+
* Each tracked promise leaves the tracker when it settles. Tracking never
|
|
6
|
+
* creates an unhandled rejection: the tracker observes a rejection, but the
|
|
7
|
+
* caller still owns the promise and its rejection.
|
|
8
|
+
*
|
|
9
|
+
* The tracker has no closed state. Each owner decides what new work does
|
|
10
|
+
* after its own `close` starts, and in what order it drains its trackers.
|
|
11
|
+
*/
|
|
12
|
+
export class PendingPromisesTracker {
|
|
13
|
+
readonly #pending = new Set<Promise<unknown>>();
|
|
14
|
+
|
|
15
|
+
/** The number of tracked promises that have not settled yet. */
|
|
16
|
+
get size(): number {
|
|
17
|
+
return this.#pending.size;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Tracks `promise` until it settles, and returns it unchanged. */
|
|
21
|
+
// oxlint-disable-next-line typescript/promise-function-async -- Returns the same promise it tracks.
|
|
22
|
+
track<T>(promise: Promise<T>): Promise<T> {
|
|
23
|
+
this.#pending.add(promise);
|
|
24
|
+
const untrack = () => {
|
|
25
|
+
this.#pending.delete(promise);
|
|
26
|
+
};
|
|
27
|
+
// oxlint-disable-next-line no-void -- Untracks on settle; the caller owns the promise and its rejection.
|
|
28
|
+
void promise.then(untrack, untrack);
|
|
29
|
+
return promise;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Calls `start` and tracks its result. The promise is tracked before
|
|
34
|
+
* `start` runs, so a `drain` that `start` itself triggers synchronously
|
|
35
|
+
* still waits for it. A synchronous throw from `start` becomes a rejection.
|
|
36
|
+
*/
|
|
37
|
+
// oxlint-disable-next-line typescript/promise-function-async -- Returns the tracked promise, and must call start synchronously.
|
|
38
|
+
run<T>(start: () => Promise<T>): Promise<T> {
|
|
39
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
|
40
|
+
// oxlint-disable-next-line no-void -- track returns the same promise, which run returns.
|
|
41
|
+
void this.track(promise);
|
|
42
|
+
try {
|
|
43
|
+
resolve(start());
|
|
44
|
+
} catch (error) {
|
|
45
|
+
reject(error);
|
|
46
|
+
}
|
|
47
|
+
return promise;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves once no tracked promise is pending, including promises tracked
|
|
52
|
+
* while it waits. It loops because settling work can start more work, for
|
|
53
|
+
* example a producer that reads other keys from its own wrapper. It never
|
|
54
|
+
* rejects.
|
|
55
|
+
*/
|
|
56
|
+
async drain(): Promise<void> {
|
|
57
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Loops until nothing is pending, because settling work can track more.
|
|
58
|
+
while (this.#pending.size > 0) {
|
|
59
|
+
await Promise.allSettled(this.#pending);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Tagged } from "type-fest";
|
|
2
|
+
import { instantiateTaggedType } from "type-party/runtime/tagged-types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A reading of `performance.now()`, in milliseconds since the time origin.
|
|
6
|
+
*
|
|
7
|
+
* The tag keeps other numbers out of a parameter that expects a reading of
|
|
8
|
+
* this clock. Examples are a `Date.now()` time, which uses a different
|
|
9
|
+
* origin, and a duration.
|
|
10
|
+
*/
|
|
11
|
+
export type PerformanceNow = Tagged<number, "PerformanceNow">;
|
|
12
|
+
|
|
13
|
+
/** Reads `performance.now()`. */
|
|
14
|
+
export function performanceNow(): PerformanceNow {
|
|
15
|
+
return instantiateTaggedType<PerformanceNow>(performance.now());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The milliseconds that passed between `start` and now. */
|
|
19
|
+
export function msSince(start: PerformanceNow): number {
|
|
20
|
+
return performance.now() - start;
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports that a caller broke a contract of this package. Examples are a
|
|
3
|
+
* `dispatch` or `schedule` callback that returns the wrong number of results,
|
|
4
|
+
* and a `schedule` callback that requests a key it is starting a task for.
|
|
5
|
+
*/
|
|
6
|
+
export class SchedulingContractError extends Error {
|
|
7
|
+
override readonly name: string = "SchedulingContractError";
|
|
8
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const GENERATOR_HINT =
|
|
2
|
+
"For async work, use a generator callback with `yield* waitFor(...)`.";
|
|
3
|
+
export const TRAVERSAL_HINT =
|
|
4
|
+
"For async work, use a generator callback with `yield* waitFor(...)` in timeSlicedMap or timeSlicedForEach first.";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns `value` unchanged, or throws a `TypeError` if it is a thenable.
|
|
8
|
+
*
|
|
9
|
+
* TypeScript cannot reject a promise result without also rejecting generic
|
|
10
|
+
* results, so the synchronous callback rule is checked here at runtime.
|
|
11
|
+
*/
|
|
12
|
+
export function assertNotThenable<Value>(
|
|
13
|
+
value: Value,
|
|
14
|
+
helper: string,
|
|
15
|
+
hint: string,
|
|
16
|
+
): Value {
|
|
17
|
+
if (
|
|
18
|
+
((typeof value === "object" && value !== null) ||
|
|
19
|
+
typeof value === "function") &&
|
|
20
|
+
"then" in value &&
|
|
21
|
+
typeof value.then === "function"
|
|
22
|
+
) {
|
|
23
|
+
throw new TypeError(
|
|
24
|
+
`${helper} received a promise or other thenable from a synchronous callback. ${hint}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { TRAVERSAL_HINT, assertNotThenable } from "./synchronous-result.js";
|
|
2
|
+
import { TimeSliceBudget } from "./time-slicing.js";
|
|
3
|
+
|
|
4
|
+
async function findFirstMatch<Item>(
|
|
5
|
+
helper: string,
|
|
6
|
+
items: Iterable<Item>,
|
|
7
|
+
predicate: (item: Item, index: number) => boolean,
|
|
8
|
+
budget: TimeSliceBudget,
|
|
9
|
+
): Promise<{ item: Item } | undefined> {
|
|
10
|
+
const position = { index: 0 };
|
|
11
|
+
// oxlint-disable-next-line zingage/no-restricted-syntax -- Stop at the first match; native iteration closes the iterator without advancing it.
|
|
12
|
+
for (const item of items) {
|
|
13
|
+
const matched = budget.measure(() =>
|
|
14
|
+
assertNotThenable(
|
|
15
|
+
predicate(item, position.index),
|
|
16
|
+
helper,
|
|
17
|
+
TRAVERSAL_HINT,
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
position.index += 1;
|
|
21
|
+
const pending = budget.yieldIfNeeded();
|
|
22
|
+
if (pending !== undefined) await pending;
|
|
23
|
+
if (matched) return { item };
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns whether any item matches a synchronous predicate, in input order.
|
|
30
|
+
*
|
|
31
|
+
* Stops at the first match. It does not advance the iterable past that item,
|
|
32
|
+
* and it builds no intermediate array. Measures each predicate call, including
|
|
33
|
+
* calls that throw. One predicate call cannot be interrupted. Throws a
|
|
34
|
+
* `TypeError` if the predicate returns a promise or other thenable.
|
|
35
|
+
* The optional budget defaults to a new independent `TimeSliceBudget`.
|
|
36
|
+
*
|
|
37
|
+
* @example Check for a negative number.
|
|
38
|
+
* const hasNegative = await timeSlicedSome([3, -1, 2], (value) => value < 0);
|
|
39
|
+
*
|
|
40
|
+
* @example Share a budget with other work, and stop early on a lazy source.
|
|
41
|
+
* const budget = new TimeSliceBudget();
|
|
42
|
+
* const found = await timeSlicedSome(map.values(), (entry) => entry.active, budget);
|
|
43
|
+
*/
|
|
44
|
+
export async function timeSlicedSome<Item>(
|
|
45
|
+
items: Iterable<Item>,
|
|
46
|
+
predicate: (item: Item, index: number) => boolean,
|
|
47
|
+
budget = new TimeSliceBudget(),
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
return (
|
|
50
|
+
(await findFirstMatch("timeSlicedSome", items, predicate, budget)) !==
|
|
51
|
+
undefined
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns the first item that matches a type-guard predicate, narrowed.
|
|
57
|
+
*
|
|
58
|
+
* @example Find the first string.
|
|
59
|
+
* const text = await timeSlicedFind(
|
|
60
|
+
* [1, "a", 2],
|
|
61
|
+
* (value): value is string => typeof value === "string",
|
|
62
|
+
* );
|
|
63
|
+
*/
|
|
64
|
+
export function timeSlicedFind<Item, Found extends Item>(
|
|
65
|
+
items: Iterable<Item>,
|
|
66
|
+
predicate: (item: Item, index: number) => item is Found,
|
|
67
|
+
budget?: TimeSliceBudget,
|
|
68
|
+
): Promise<Found | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* Returns the first item that matches a synchronous predicate, or `undefined`.
|
|
71
|
+
*
|
|
72
|
+
* Stops at the first match. It does not advance the iterable past that item,
|
|
73
|
+
* and it builds no intermediate array. Measures each predicate call, including
|
|
74
|
+
* calls that throw. One predicate call cannot be interrupted. Throws a
|
|
75
|
+
* `TypeError` if the predicate returns a promise or other thenable. As with
|
|
76
|
+
* `Array.prototype.find`, a matching `undefined` item also returns `undefined`.
|
|
77
|
+
* The optional budget defaults to a new independent `TimeSliceBudget`.
|
|
78
|
+
*
|
|
79
|
+
* @example Find the first even number.
|
|
80
|
+
* const even = await timeSlicedFind([1, 4, 6], (value) => value % 2 === 0);
|
|
81
|
+
*
|
|
82
|
+
* @example Use the index and a shared budget.
|
|
83
|
+
* const budget = new TimeSliceBudget();
|
|
84
|
+
* const late = await timeSlicedFind(["a", "b"], (_, index) => index > 0, budget);
|
|
85
|
+
*/
|
|
86
|
+
export function timeSlicedFind<Item>(
|
|
87
|
+
items: Iterable<Item>,
|
|
88
|
+
predicate: (item: Item, index: number) => boolean,
|
|
89
|
+
budget?: TimeSliceBudget,
|
|
90
|
+
): Promise<Item | undefined>;
|
|
91
|
+
export async function timeSlicedFind<Item>(
|
|
92
|
+
items: Iterable<Item>,
|
|
93
|
+
predicate: (item: Item, index: number) => boolean,
|
|
94
|
+
budget = new TimeSliceBudget(),
|
|
95
|
+
): Promise<Item | undefined> {
|
|
96
|
+
return (await findFirstMatch("timeSlicedFind", items, predicate, budget))
|
|
97
|
+
?.item;
|
|
98
|
+
}
|
package/src/time-slicing.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { setImmediate } from "node:timers/promises";
|
|
3
|
+
import {
|
|
4
|
+
GENERATOR_HINT,
|
|
5
|
+
TRAVERSAL_HINT,
|
|
6
|
+
assertNotThenable,
|
|
7
|
+
} from "./synchronous-result.js";
|
|
3
8
|
|
|
4
9
|
const DEFAULT_TIME_SLICE_MS = 4;
|
|
5
10
|
const performanceNow = performance.now.bind(performance);
|
|
6
11
|
|
|
7
|
-
type Synchronous<Result> = Result extends PromiseLike<unknown> ? never : Result;
|
|
8
|
-
|
|
9
12
|
/**
|
|
10
13
|
* Tracks measured synchronous work before yielding to the event loop.
|
|
11
14
|
*
|
|
@@ -55,6 +58,8 @@ export class TimeSliceBudget {
|
|
|
55
58
|
*
|
|
56
59
|
* Nested measurements on this budget count once. This method does not yield.
|
|
57
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.
|
|
58
63
|
*
|
|
59
64
|
* @example Measure one segment and then check the budget.
|
|
60
65
|
* const budget = new TimeSliceBudget();
|
|
@@ -62,12 +67,16 @@ export class TimeSliceBudget {
|
|
|
62
67
|
* const pending = budget.yieldIfNeeded();
|
|
63
68
|
* if (pending !== undefined) await pending;
|
|
64
69
|
*/
|
|
65
|
-
measure<Result>(work: () => Result
|
|
70
|
+
measure<Result>(work: () => Result): Result {
|
|
66
71
|
const outermost = this.#depth === 0;
|
|
67
72
|
const startedAt = outermost ? this.#now() : 0;
|
|
68
73
|
this.#depth += 1;
|
|
69
74
|
try {
|
|
70
|
-
return
|
|
75
|
+
return assertNotThenable(
|
|
76
|
+
work(),
|
|
77
|
+
"TimeSliceBudget.measure",
|
|
78
|
+
GENERATOR_HINT,
|
|
79
|
+
);
|
|
71
80
|
} finally {
|
|
72
81
|
this.#depth -= 1;
|
|
73
82
|
if (outermost) this.#remainingMs -= this.#now() - startedAt;
|
|
@@ -197,19 +206,17 @@ async function runCallbackGenerator<Result>(
|
|
|
197
206
|
}
|
|
198
207
|
|
|
199
208
|
async function traverse<Item, Result>(
|
|
209
|
+
helper: string,
|
|
200
210
|
items: Iterable<Item>,
|
|
201
|
-
callback: (
|
|
202
|
-
item: Item,
|
|
203
|
-
index: number,
|
|
204
|
-
) => (Result & Synchronous<Result>) | CallbackGenerator<Result>,
|
|
211
|
+
callback: (item: Item, index: number) => Result | CallbackGenerator<Result>,
|
|
205
212
|
accept: (result: Result) => void,
|
|
206
213
|
budget: TimeSliceBudget,
|
|
207
214
|
): Promise<void> {
|
|
208
215
|
const position = { index: 0 };
|
|
209
216
|
// oxlint-disable-next-line zingage/no-restricted-syntax -- Native iteration owns cleanup while callbacks and budget yields settle sequentially.
|
|
210
217
|
for (const item of items) {
|
|
211
|
-
const value = budget.measure
|
|
212
|
-
callback(item, position.index),
|
|
218
|
+
const value = budget.measure(() =>
|
|
219
|
+
assertNotThenable(callback(item, position.index), helper, GENERATOR_HINT),
|
|
213
220
|
);
|
|
214
221
|
const result = isCallbackGenerator<Result>(value)
|
|
215
222
|
? await runCallbackGenerator(value, budget)
|
|
@@ -228,6 +235,9 @@ async function traverse<Item, Result>(
|
|
|
228
235
|
* finishes before the next item starts. Measures synchronous callback work and
|
|
229
236
|
* every generator `next()`/`throw()` segment, excluding promise waits.
|
|
230
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.
|
|
231
241
|
* The optional budget defaults to a new independent `TimeSliceBudget`.
|
|
232
242
|
*
|
|
233
243
|
* @example Run a synchronous visitor.
|
|
@@ -247,23 +257,22 @@ export function timeSlicedForEach<Item, Result>(
|
|
|
247
257
|
/**
|
|
248
258
|
* Runs a synchronous visitor in input order with an optional processing budget.
|
|
249
259
|
*
|
|
260
|
+
* Throws a `TypeError` if the visitor returns a promise or other thenable.
|
|
261
|
+
*
|
|
250
262
|
* @example
|
|
251
263
|
* await timeSlicedForEach([1, 2], (value) => console.log(value));
|
|
252
264
|
*/
|
|
253
265
|
export function timeSlicedForEach<Item, Result>(
|
|
254
266
|
items: Iterable<Item>,
|
|
255
|
-
callback: (item: Item, index: number) => Result
|
|
267
|
+
callback: (item: Item, index: number) => Result,
|
|
256
268
|
budget?: TimeSliceBudget,
|
|
257
269
|
): Promise<void>;
|
|
258
270
|
export async function timeSlicedForEach<Item, Result>(
|
|
259
271
|
items: Iterable<Item>,
|
|
260
|
-
callback: (
|
|
261
|
-
item: Item,
|
|
262
|
-
index: number,
|
|
263
|
-
) => (Result & Synchronous<Result>) | CallbackGenerator<Result>,
|
|
272
|
+
callback: (item: Item, index: number) => Result | CallbackGenerator<Result>,
|
|
264
273
|
budget = new TimeSliceBudget(),
|
|
265
274
|
): Promise<void> {
|
|
266
|
-
await traverse(items, callback, () => {}, budget);
|
|
275
|
+
await traverse("timeSlicedForEach", items, callback, () => {}, budget);
|
|
267
276
|
}
|
|
268
277
|
|
|
269
278
|
/**
|
|
@@ -272,7 +281,9 @@ export async function timeSlicedForEach<Item, Result>(
|
|
|
272
281
|
* Accepts synchronous callbacks or generators that yield promises. Each generator
|
|
273
282
|
* finishes before the next item starts. Measures every synchronous segment,
|
|
274
283
|
* including `throw()` recovery and result accumulation. Promise waits and iterator
|
|
275
|
-
* advancement are unmeasured. Ordinary async callbacks are not supported
|
|
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.
|
|
276
287
|
* The optional budget defaults to a new independent `TimeSliceBudget`.
|
|
277
288
|
*
|
|
278
289
|
* @example Transform synchronous values.
|
|
@@ -292,24 +303,24 @@ export function timeSlicedMap<Item, Result>(
|
|
|
292
303
|
/**
|
|
293
304
|
* Maps synchronous values in input order with an optional processing budget.
|
|
294
305
|
*
|
|
306
|
+
* Throws a `TypeError` if the callback returns a promise or other thenable.
|
|
307
|
+
*
|
|
295
308
|
* @example
|
|
296
309
|
* const doubled = await timeSlicedMap([1, 2], (value) => value * 2);
|
|
297
310
|
*/
|
|
298
311
|
export function timeSlicedMap<Item, Result>(
|
|
299
312
|
items: Iterable<Item>,
|
|
300
|
-
callback: (item: Item, index: number) => Result
|
|
313
|
+
callback: (item: Item, index: number) => Result,
|
|
301
314
|
budget?: TimeSliceBudget,
|
|
302
315
|
): Promise<Result[]>;
|
|
303
316
|
export async function timeSlicedMap<Item, Result>(
|
|
304
317
|
items: Iterable<Item>,
|
|
305
|
-
callback: (
|
|
306
|
-
item: Item,
|
|
307
|
-
index: number,
|
|
308
|
-
) => (Result & Synchronous<Result>) | CallbackGenerator<Result>,
|
|
318
|
+
callback: (item: Item, index: number) => Result | CallbackGenerator<Result>,
|
|
309
319
|
budget = new TimeSliceBudget(),
|
|
310
320
|
): Promise<Result[]> {
|
|
311
321
|
const results: Result[] = [];
|
|
312
322
|
await traverse(
|
|
323
|
+
"timeSlicedMap",
|
|
313
324
|
items,
|
|
314
325
|
callback,
|
|
315
326
|
(result) => {
|
|
@@ -370,7 +381,8 @@ export async function timeSlicedFlatMap<Item, Result>(
|
|
|
370
381
|
* Returns items whose synchronous predicate is true, preserving input order.
|
|
371
382
|
*
|
|
372
383
|
* Measures predicate work and result accumulation. One predicate call cannot be
|
|
373
|
-
* interrupted.
|
|
384
|
+
* interrupted. Throws a `TypeError` if the predicate returns a promise or other
|
|
385
|
+
* thenable. The optional budget defaults to a new independent instance.
|
|
374
386
|
*
|
|
375
387
|
* @example Keep odd numbers.
|
|
376
388
|
* const odd = await timeSlicedFilter([1, 2, 3], (value) => value % 2 === 1);
|
|
@@ -390,7 +402,13 @@ export async function timeSlicedFilter<Item>(
|
|
|
390
402
|
await timeSlicedForEach(
|
|
391
403
|
items,
|
|
392
404
|
(item, index) => {
|
|
393
|
-
if (
|
|
405
|
+
if (
|
|
406
|
+
assertNotThenable(
|
|
407
|
+
predicate(item, index),
|
|
408
|
+
"timeSlicedFilter",
|
|
409
|
+
TRAVERSAL_HINT,
|
|
410
|
+
)
|
|
411
|
+
) {
|
|
394
412
|
// oxlint-disable-next-line zingage/no-restricted-syntax -- Append to the local result without quadratic copying.
|
|
395
413
|
results.push(item);
|
|
396
414
|
}
|
|
@@ -404,6 +422,8 @@ export async function timeSlicedFilter<Item>(
|
|
|
404
422
|
* Groups items by a synchronous key, preserving key and group insertion order.
|
|
405
423
|
*
|
|
406
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.
|
|
407
427
|
* The optional budget defaults to a new independent `TimeSliceBudget`.
|
|
408
428
|
*
|
|
409
429
|
* @example Group strings by length.
|
|
@@ -417,14 +437,18 @@ export async function timeSlicedFilter<Item>(
|
|
|
417
437
|
*/
|
|
418
438
|
export async function timeSlicedGroupBy<Item, Key>(
|
|
419
439
|
items: Iterable<Item>,
|
|
420
|
-
keyOf: (item: Item, index: number) => Key
|
|
440
|
+
keyOf: (item: Item, index: number) => Key,
|
|
421
441
|
budget = new TimeSliceBudget(),
|
|
422
442
|
): Promise<Map<Key, Item[]>> {
|
|
423
443
|
const groups = new Map<Key, Item[]>();
|
|
424
444
|
await timeSlicedForEach(
|
|
425
445
|
items,
|
|
426
446
|
(item, index) => {
|
|
427
|
-
const key =
|
|
447
|
+
const key = assertNotThenable(
|
|
448
|
+
keyOf(item, index),
|
|
449
|
+
"timeSlicedGroupBy",
|
|
450
|
+
TRAVERSAL_HINT,
|
|
451
|
+
);
|
|
428
452
|
const group = groups.get(key);
|
|
429
453
|
if (group === undefined) groups.set(key, [item]);
|
|
430
454
|
// oxlint-disable-next-line zingage/no-restricted-syntax -- Each local group accumulates without quadratic copying.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CollapsedTaskCreator,
|
|
3
|
+
type TaskStartReason,
|
|
4
|
+
} from "./collapsed-task-creator.js";
|
|
5
|
+
import { defaultLoggersByComponent, type Logger } from "./logger.js";
|
|
6
|
+
|
|
7
|
+
// The creator reserves `undefined` for inputs that never share work, but here
|
|
8
|
+
// `undefined` is an ordinary key. So it stands in for `undefined` with a
|
|
9
|
+
// key that no caller can produce.
|
|
10
|
+
const undefinedKey = Symbol("undefined key");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The "started" log line for each reason a call starts a task. A call here
|
|
14
|
+
* has no signal, so its task is never cancelled, and its key is never
|
|
15
|
+
* `undefined` for the creator. But every reason has a line, so the log stays
|
|
16
|
+
* accurate if that changes.
|
|
17
|
+
*/
|
|
18
|
+
const startedMessages: Record<TaskStartReason, string> = {
|
|
19
|
+
noPendingTask:
|
|
20
|
+
"started new task b/c there was no in-progress task for these args",
|
|
21
|
+
pendingTaskTooOld:
|
|
22
|
+
"started new task; there _was_ an in-progress one, but it's too old",
|
|
23
|
+
pendingTaskCancelled:
|
|
24
|
+
"started new task; there _was_ an in-progress one, but it was cancelled",
|
|
25
|
+
noKey: "started new task b/c these args have no key",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wraps a function that starts a task and returns a promise for its result.
|
|
30
|
+
* The returned function behaves like the original, except that a call reuses
|
|
31
|
+
* the promise of an earlier call if and only if: 1) the calls have the same
|
|
32
|
+
* key, which `makeKey` derives from their arguments [a la memoize]; 2) less
|
|
33
|
+
* than `collapseTasksMs` milliseconds have passed since the earlier call
|
|
34
|
+
* started its task [a la throttle]; and 3) that task is still running
|
|
35
|
+
* [unique]. Otherwise, the call calls through to the original function. So it
|
|
36
|
+
* is sort of a combination of memoize, throttle, and promise chaining.
|
|
37
|
+
*
|
|
38
|
+
* It runs on {@link CollapsedTaskCreator}: each call is one task request,
|
|
39
|
+
* whose input carries the call's arguments and key. See that class for why
|
|
40
|
+
* these semantics are useful.
|
|
41
|
+
*
|
|
42
|
+
* It does not support task cancellation. A call takes no `AbortSignal`, so no
|
|
43
|
+
* individual caller can cancel a task that other callers share. Users who
|
|
44
|
+
* need cancellation should use `CollapsedTaskCreator` directly.
|
|
45
|
+
*
|
|
46
|
+
* @param taskCreator The task creation function
|
|
47
|
+
* @param collapseTasksMs The number of milliseconds up until which, if a caller
|
|
48
|
+
* tries to start the same task while a previous version of the task is still
|
|
49
|
+
* running, the promise for the result of the currently-running task will be
|
|
50
|
+
* returned instead.
|
|
51
|
+
* @param makeKey A function for converting the arguments passed to the task
|
|
52
|
+
* creation into a cache key, like in your standard memoize implementation.
|
|
53
|
+
* @param logger Receives the trace lines that each call writes.
|
|
54
|
+
*/
|
|
55
|
+
export function wrapWithTaskCollapsing<Args extends unknown[], Key, Result>(
|
|
56
|
+
taskCreator: (...args: Args) => Promise<Result>,
|
|
57
|
+
collapseTasksMs: number,
|
|
58
|
+
makeKey: (args: Args) => Key,
|
|
59
|
+
logger: Logger = defaultLoggersByComponent["collapsed-task-creator"],
|
|
60
|
+
) {
|
|
61
|
+
const logTrace = logger.bind(null, "collapsed-task-creator", "trace");
|
|
62
|
+
|
|
63
|
+
// The creator's input carries the key, so `makeKey` runs once per call and
|
|
64
|
+
// every log line below can include the key.
|
|
65
|
+
const creator = new CollapsedTaskCreator({
|
|
66
|
+
collapseTasksMs,
|
|
67
|
+
makeKey: ({ taskKey }: { args: Args; taskKey: Key }) =>
|
|
68
|
+
taskKey === undefined ? undefinedKey : taskKey,
|
|
69
|
+
schedule: (toStart) =>
|
|
70
|
+
toStart.map(({ input: { args, taskKey }, startReason }) => {
|
|
71
|
+
logTrace(startedMessages[startReason], args);
|
|
72
|
+
const taskRes = taskCreator(...args);
|
|
73
|
+
logTrace("pending = new state for taskKey/args", { args, taskKey });
|
|
74
|
+
return {
|
|
75
|
+
result: taskRes.finally(() => {
|
|
76
|
+
logTrace("completed = new state for taskKey/args", {
|
|
77
|
+
args,
|
|
78
|
+
taskKey,
|
|
79
|
+
});
|
|
80
|
+
}),
|
|
81
|
+
handle: undefined,
|
|
82
|
+
};
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return async (...args: Args) => {
|
|
87
|
+
const taskKey = makeKey(args);
|
|
88
|
+
logTrace("requested = new state for taskKey/args", { args, taskKey });
|
|
89
|
+
|
|
90
|
+
const [{ result, rode }] = creator.startOrRideTasks([
|
|
91
|
+
{ input: { args, taskKey } },
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
if (rode) {
|
|
95
|
+
logTrace(
|
|
96
|
+
"reusing result from prior, still-in-progress run of task for args/taskKey",
|
|
97
|
+
{ args, taskKey },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
};
|
|
103
|
+
}
|