@praxisjs/concurrent 1.2.13 → 1.3.1
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/CHANGELOG.md +41 -0
- package/dist/__tests__/decorators.test.js +60 -3
- package/dist/__tests__/decorators.test.js.map +1 -1
- package/dist/__tests__/pool.test.js +103 -70
- package/dist/__tests__/pool.test.js.map +1 -1
- package/dist/__tests__/queue.test.js +86 -71
- package/dist/__tests__/queue.test.js.map +1 -1
- package/dist/__tests__/task.test.js +90 -68
- package/dist/__tests__/task.test.js.map +1 -1
- package/dist/__tests__/utils.test.d.ts +2 -0
- package/dist/__tests__/utils.test.d.ts.map +1 -0
- package/dist/__tests__/utils.test.js +32 -0
- package/dist/__tests__/utils.test.js.map +1 -0
- package/dist/decorators.d.ts +13 -3
- package/dist/decorators.d.ts.map +1 -1
- package/dist/decorators.js +8 -3
- package/dist/decorators.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pool.d.ts +4 -1
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +29 -5
- package/dist/pool.js.map +1 -1
- package/dist/queue.d.ts +3 -1
- package/dist/queue.d.ts.map +1 -1
- package/dist/queue.js +35 -9
- package/dist/queue.js.map +1 -1
- package/dist/task.d.ts +3 -1
- package/dist/task.d.ts.map +1 -1
- package/dist/task.js +16 -3
- package/dist/task.js.map +1 -1
- package/dist/utils.d.ts +9 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +26 -0
- package/dist/utils.js.map +1 -0
- package/package.json +4 -4
- package/src/__tests__/decorators.test.ts +78 -3
- package/src/__tests__/pool.test.ts +110 -102
- package/src/__tests__/queue.test.ts +103 -114
- package/src/__tests__/task.test.ts +96 -90
- package/src/__tests__/utils.test.ts +48 -0
- package/src/decorators.ts +23 -6
- package/src/index.ts +1 -0
- package/src/pool.ts +33 -4
- package/src/queue.ts +36 -8
- package/src/task.ts +18 -2
- package/src/utils.ts +24 -0
|
@@ -2,7 +2,20 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { task } from "../task";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Abortable delay — rejects with AbortError when signal fires. */
|
|
6
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const timer = setTimeout(resolve, ms);
|
|
9
|
+
signal?.addEventListener("abort", () => {
|
|
10
|
+
clearTimeout(timer);
|
|
11
|
+
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
12
|
+
}, { once: true });
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── Without AbortSignal (existing behaviour) ───────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe("task — without signal", () => {
|
|
6
19
|
it("resolves with the function result", async () => {
|
|
7
20
|
const t = task(async (x: unknown) => (x as number) + 1);
|
|
8
21
|
expect(await t(9)).toBe(10);
|
|
@@ -26,9 +39,7 @@ describe("task", () => {
|
|
|
26
39
|
});
|
|
27
40
|
|
|
28
41
|
it("captures errors in the error signal", async () => {
|
|
29
|
-
const t = task(async () => {
|
|
30
|
-
throw new Error("task failed");
|
|
31
|
-
});
|
|
42
|
+
const t = task(async () => { throw new Error("task failed"); });
|
|
32
43
|
const result = await t();
|
|
33
44
|
expect(result).toBeUndefined();
|
|
34
45
|
expect(t.error()?.message).toBe("task failed");
|
|
@@ -41,8 +52,6 @@ describe("task", () => {
|
|
|
41
52
|
return "ok";
|
|
42
53
|
});
|
|
43
54
|
await t();
|
|
44
|
-
expect(t.error()).not.toBeNull();
|
|
45
|
-
|
|
46
55
|
shouldFail = false;
|
|
47
56
|
await t();
|
|
48
57
|
expect(t.error()).toBeNull();
|
|
@@ -52,148 +61,145 @@ describe("task", () => {
|
|
|
52
61
|
it("cancelAll() discards in-flight results", async () => {
|
|
53
62
|
let resolve!: (v: string) => void;
|
|
54
63
|
const t = task(() => new Promise<string>((r) => { resolve = r; }));
|
|
55
|
-
|
|
56
64
|
const p = t();
|
|
57
65
|
t.cancelAll();
|
|
58
66
|
resolve("stale");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(result).toBeUndefined();
|
|
67
|
+
expect(await p).toBeUndefined();
|
|
62
68
|
expect(t.loading()).toBe(false);
|
|
63
69
|
});
|
|
64
70
|
|
|
65
71
|
it("cancelAll() during a failing task discards the error", async () => {
|
|
66
72
|
let reject!: (err: unknown) => void;
|
|
67
73
|
const t = task(() => new Promise<string>((_res, rej) => { reject = rej; }));
|
|
68
|
-
|
|
69
74
|
const p = t();
|
|
70
75
|
t.cancelAll();
|
|
71
76
|
reject(new Error("cancelled"));
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// The error was from a stale run — result is undefined, no error stored
|
|
75
|
-
expect(result).toBeUndefined();
|
|
77
|
+
expect(await p).toBeUndefined();
|
|
76
78
|
expect(t.error()).toBeNull();
|
|
77
79
|
});
|
|
78
80
|
|
|
79
81
|
it("wraps non-Error throws in an Error object", async () => {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
});
|
|
84
|
-
const result = await t();
|
|
85
|
-
expect(result).toBeUndefined();
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
|
83
|
+
const t = task(async () => { throw "string-error"; });
|
|
84
|
+
await t();
|
|
86
85
|
expect(t.error()).toBeInstanceOf(Error);
|
|
87
86
|
expect(t.error()?.message).toBe("string-error");
|
|
88
87
|
});
|
|
89
88
|
|
|
89
|
+
it("AbortError thrown by fn itself (no cancelAll) is not stored in error()", async () => {
|
|
90
|
+
const t = task(async () => { throw new DOMException("Aborted", "AbortError"); });
|
|
91
|
+
const result = await t();
|
|
92
|
+
expect(result).toBeUndefined();
|
|
93
|
+
expect(t.error()).toBeNull();
|
|
94
|
+
expect(t.loading()).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
90
97
|
it("second run while first is pending discards the first result", async () => {
|
|
91
98
|
let resolveFirst!: (v: string) => void;
|
|
92
99
|
let resolveSecond!: (v: string) => void;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
(_run === 1
|
|
100
|
+
const t = task((run: unknown) =>
|
|
101
|
+
run === 1
|
|
96
102
|
? new Promise<string>((r) => { resolveFirst = r; })
|
|
97
|
-
: new Promise<string>((r) => { resolveSecond = r; })
|
|
103
|
+
: new Promise<string>((r) => { resolveSecond = r; }),
|
|
98
104
|
);
|
|
99
|
-
|
|
100
105
|
let run = 1;
|
|
101
|
-
const p1 = t(run++);
|
|
102
|
-
const p2 = t(run++);
|
|
103
|
-
|
|
106
|
+
const p1 = t(run++);
|
|
107
|
+
const p2 = t(run++);
|
|
104
108
|
resolveSecond("result-2");
|
|
105
|
-
resolveFirst("result-1");
|
|
106
|
-
|
|
109
|
+
resolveFirst("result-1");
|
|
107
110
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
108
111
|
expect(r2).toBe("result-2");
|
|
109
|
-
expect(r1).toBeUndefined();
|
|
112
|
+
expect(r1).toBeUndefined();
|
|
110
113
|
expect(t.lastResult()).toBe("result-2");
|
|
111
114
|
});
|
|
112
115
|
|
|
113
116
|
it("synchronous function (non-async) resolves correctly via await", async () => {
|
|
114
|
-
// await on a non-Promise simply resolves the value
|
|
115
117
|
const t = task((() => 42) as unknown as () => Promise<number>);
|
|
116
|
-
|
|
117
|
-
expect(result).toBe(42);
|
|
118
|
+
expect(await t()).toBe(42);
|
|
118
119
|
expect(t.lastResult()).toBe(42);
|
|
119
|
-
expect(t.loading()).toBe(false);
|
|
120
120
|
});
|
|
121
|
+
});
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
const t = task(async () => "fresh");
|
|
124
|
-
let resolve!: (v: string) => void;
|
|
125
|
-
const staleTask = task(() => new Promise<string>((r) => { resolve = r; }));
|
|
123
|
+
// ── With AbortSignal (opt-in via first param named "signal") ──────────────────
|
|
126
124
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
await
|
|
125
|
+
describe("task — with signal", () => {
|
|
126
|
+
it("resolves with the function result", async () => {
|
|
127
|
+
const t = task(async (signal, x: unknown) => { void (signal as AbortSignal); return (x as number) + 1; });
|
|
128
|
+
expect(await t(9)).toBe(10);
|
|
129
|
+
});
|
|
131
130
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
it("passes a live AbortSignal to fn", async () => {
|
|
132
|
+
let capturedSignal!: AbortSignal;
|
|
133
|
+
const t = task(async (signal) => { capturedSignal = signal as AbortSignal; return "done"; });
|
|
134
|
+
await t();
|
|
135
|
+
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
|
136
|
+
expect(capturedSignal.aborted).toBe(false);
|
|
136
137
|
});
|
|
137
138
|
|
|
138
|
-
it("
|
|
139
|
-
|
|
140
|
-
let
|
|
139
|
+
it("new run aborts the previous signal", async () => {
|
|
140
|
+
const signals: AbortSignal[] = [];
|
|
141
|
+
let resolveFirst!: () => void;
|
|
142
|
+
const t = task(async (signal) => {
|
|
143
|
+
signals.push(signal as AbortSignal);
|
|
144
|
+
if (signals.length === 1) await new Promise<void>((r) => { resolveFirst = r; });
|
|
145
|
+
return signals.length;
|
|
146
|
+
});
|
|
147
|
+
const p1 = t();
|
|
148
|
+
const p2 = t();
|
|
149
|
+
resolveFirst();
|
|
150
|
+
await Promise.all([p1, p2]);
|
|
151
|
+
expect(signals[0].aborted).toBe(true);
|
|
152
|
+
expect(signals[1].aborted).toBe(false);
|
|
153
|
+
});
|
|
141
154
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
it("cancelAll() aborts the current signal", async () => {
|
|
156
|
+
let capturedSignal!: AbortSignal;
|
|
157
|
+
let resolve!: () => void;
|
|
158
|
+
const t = task(async (signal) => {
|
|
159
|
+
capturedSignal = signal as AbortSignal;
|
|
160
|
+
await new Promise<void>((r) => { resolve = r; });
|
|
161
|
+
});
|
|
162
|
+
const p = t();
|
|
163
|
+
t.cancelAll();
|
|
164
|
+
resolve();
|
|
165
|
+
await p;
|
|
166
|
+
expect(capturedSignal.aborted).toBe(true);
|
|
167
|
+
});
|
|
147
168
|
|
|
169
|
+
it("AbortError from cancelAll() is not stored in error()", async () => {
|
|
170
|
+
const t = task(async (signal) => { await delay(10000, signal as AbortSignal); });
|
|
171
|
+
const p = t();
|
|
172
|
+
t.cancelAll();
|
|
173
|
+
await p;
|
|
174
|
+
expect(t.error()).toBeNull();
|
|
175
|
+
expect(t.loading()).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("AbortError from a superseded run is not stored in error()", async () => {
|
|
179
|
+
const t = task(async (signal, which: unknown) => {
|
|
180
|
+
if (which === "first") await delay(10000, signal as AbortSignal);
|
|
181
|
+
return which as string;
|
|
182
|
+
});
|
|
148
183
|
const p1 = t("first");
|
|
149
184
|
const p2 = t("second");
|
|
150
|
-
|
|
151
|
-
// second resolves before first
|
|
152
|
-
resolveSecond("second-result");
|
|
153
|
-
resolveFirst("first-result");
|
|
154
|
-
|
|
155
185
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
156
|
-
expect(
|
|
157
|
-
expect(
|
|
158
|
-
expect(t.
|
|
186
|
+
expect(r1).toBeUndefined();
|
|
187
|
+
expect(r2).toBe("second");
|
|
188
|
+
expect(t.error()).toBeNull();
|
|
159
189
|
});
|
|
160
190
|
|
|
161
191
|
it("cancelAll() called, then new call — new call works normally", async () => {
|
|
162
192
|
let resolveStale!: (v: string) => void;
|
|
163
|
-
const t = task((which: unknown) =>
|
|
193
|
+
const t = task((signal, which: unknown) =>
|
|
164
194
|
which === "stale"
|
|
165
195
|
? new Promise<string>((r) => { resolveStale = r; })
|
|
166
196
|
: Promise.resolve("fresh"),
|
|
167
197
|
);
|
|
168
|
-
|
|
169
198
|
const staleP = t("stale");
|
|
170
199
|
t.cancelAll();
|
|
171
200
|
resolveStale("stale-value");
|
|
172
201
|
await staleP;
|
|
173
|
-
|
|
174
|
-
const freshResult = await t("fresh");
|
|
175
|
-
expect(freshResult).toBe("fresh");
|
|
176
|
-
expect(t.loading()).toBe(false);
|
|
202
|
+
expect(await t("fresh")).toBe("fresh");
|
|
177
203
|
expect(t.lastResult()).toBe("fresh");
|
|
178
204
|
});
|
|
179
|
-
|
|
180
|
-
it("lastResult is null when task throws (not stale from previous run)", async () => {
|
|
181
|
-
let shouldThrow = false;
|
|
182
|
-
const t = task(async () => {
|
|
183
|
-
if (shouldThrow) throw new Error("fail");
|
|
184
|
-
return "success";
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
await t();
|
|
188
|
-
expect(t.lastResult()).toBe("success");
|
|
189
|
-
|
|
190
|
-
shouldThrow = true;
|
|
191
|
-
await t();
|
|
192
|
-
|
|
193
|
-
// After a throw, lastResult should remain the previous value
|
|
194
|
-
// (the error path does not update lastResult)
|
|
195
|
-
expect(t.error()?.message).toBe("fail");
|
|
196
|
-
// lastResult is not reset to null on error — it keeps the last successful value
|
|
197
|
-
expect(t.lastResult()).toBe("success");
|
|
198
|
-
});
|
|
199
205
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { acceptsSignal } from "../utils";
|
|
4
|
+
|
|
5
|
+
describe("acceptsSignal", () => {
|
|
6
|
+
it("returns true for a function with 'signal' as first param", () => {
|
|
7
|
+
expect(acceptsSignal((signal: AbortSignal) => signal)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns true for an async function with 'signal' as first param", () => {
|
|
11
|
+
expect(acceptsSignal(async (signal: AbortSignal, id: number) => ({ signal, id }))).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns true for a named function with 'signal' as first param", () => {
|
|
15
|
+
function loadUser(signal: AbortSignal) { return signal; }
|
|
16
|
+
expect(acceptsSignal(loadUser)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns false for a function with no params", () => {
|
|
20
|
+
expect(acceptsSignal(() => "ok")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns false for a function whose first param is not 'signal'", () => {
|
|
24
|
+
expect(acceptsSignal((id: number) => id)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns false for _signal (underscore-prefixed)", () => {
|
|
28
|
+
expect(acceptsSignal((_signal: AbortSignal) => _signal)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns false when fn.toString() contains no parentheses (regex yields no match)", () => {
|
|
32
|
+
const noParenFn = Object.assign(
|
|
33
|
+
() => undefined,
|
|
34
|
+
{ toString() { return "no parens here"; } },
|
|
35
|
+
) as unknown as () => unknown;
|
|
36
|
+
|
|
37
|
+
expect(acceptsSignal(noParenFn)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns false when fn.toString() throws", () => {
|
|
41
|
+
const badFn = Object.assign(
|
|
42
|
+
() => undefined,
|
|
43
|
+
{ toString() { throw new Error("no source"); } },
|
|
44
|
+
) as unknown as () => unknown;
|
|
45
|
+
|
|
46
|
+
expect(acceptsSignal(badFn)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/decorators.ts
CHANGED
|
@@ -4,14 +4,26 @@ import type { Computed } from "@praxisjs/shared";
|
|
|
4
4
|
import { pool } from "./pool";
|
|
5
5
|
import { queue } from "./queue";
|
|
6
6
|
import { task } from "./task";
|
|
7
|
+
import { acceptsSignal } from "./utils";
|
|
7
8
|
|
|
8
9
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
10
|
type AnyAsyncFn = (this: any, ...args: any[]) => Promise<any>;
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Strips the leading AbortSignal from a parameter tuple when present,
|
|
14
|
+
* so decorated fields expose only the user-visible arguments.
|
|
15
|
+
*
|
|
16
|
+
* Examples:
|
|
17
|
+
* [AbortSignal, number, string] → [number, string]
|
|
18
|
+
* [number, string] → [number, string] (unchanged)
|
|
19
|
+
*/
|
|
20
|
+
type DropSignalIfPresent<T extends unknown[]> =
|
|
21
|
+
T extends [AbortSignal, ...infer R] ? R : T;
|
|
22
|
+
|
|
11
23
|
// ── Decorated types ────────────────────────────────────────────────────────────
|
|
12
24
|
|
|
13
25
|
export type TaskDecorated<T extends AnyAsyncFn> =
|
|
14
|
-
((...args: Parameters<T
|
|
26
|
+
((...args: DropSignalIfPresent<Parameters<T>>) => ReturnType<T>) & {
|
|
15
27
|
loading: Computed<boolean>;
|
|
16
28
|
error: Computed<Error | null>;
|
|
17
29
|
lastResult: Computed<Awaited<ReturnType<T>> | null>;
|
|
@@ -19,7 +31,7 @@ export type TaskDecorated<T extends AnyAsyncFn> =
|
|
|
19
31
|
};
|
|
20
32
|
|
|
21
33
|
export type QueueDecorated<T extends AnyAsyncFn> =
|
|
22
|
-
((...args: Parameters<T
|
|
34
|
+
((...args: DropSignalIfPresent<Parameters<T>>) => ReturnType<T>) & {
|
|
23
35
|
loading: Computed<boolean>;
|
|
24
36
|
pending: Computed<number>;
|
|
25
37
|
error: Computed<Error | null>;
|
|
@@ -27,11 +39,12 @@ export type QueueDecorated<T extends AnyAsyncFn> =
|
|
|
27
39
|
};
|
|
28
40
|
|
|
29
41
|
export type PoolDecorated<T extends AnyAsyncFn> =
|
|
30
|
-
((...args: Parameters<T
|
|
42
|
+
((...args: DropSignalIfPresent<Parameters<T>>) => ReturnType<T>) & {
|
|
31
43
|
loading: Computed<boolean>;
|
|
32
44
|
active: Computed<number>;
|
|
33
45
|
pending: Computed<number>;
|
|
34
46
|
error: Computed<Error | null>;
|
|
47
|
+
cancelAll(): void;
|
|
35
48
|
};
|
|
36
49
|
|
|
37
50
|
// ── Type helpers ───────────────────────────────────────────────────────────────
|
|
@@ -51,9 +64,11 @@ export function Task(methodName: string) {
|
|
|
51
64
|
return createFieldDecorator({
|
|
52
65
|
bind(instance: object, _name: string) {
|
|
53
66
|
const inst = instance as Record<string, AnyAsyncFn>;
|
|
67
|
+
// Detect signal on the unbound method — bound functions lose their source.
|
|
68
|
+
const wantsSignal = acceptsSignal(inst[methodName]);
|
|
54
69
|
return {
|
|
55
70
|
descriptor: {
|
|
56
|
-
value: task(inst[methodName].bind(instance)) as unknown,
|
|
71
|
+
value: task(inst[methodName].bind(instance), { signal: wantsSignal }) as unknown,
|
|
57
72
|
writable: true,
|
|
58
73
|
},
|
|
59
74
|
};
|
|
@@ -67,9 +82,10 @@ export function Queue(methodName: string) {
|
|
|
67
82
|
return createFieldDecorator({
|
|
68
83
|
bind(instance: object, _name: string) {
|
|
69
84
|
const inst = instance as Record<string, AnyAsyncFn>;
|
|
85
|
+
const wantsSignal = acceptsSignal(inst[methodName]);
|
|
70
86
|
return {
|
|
71
87
|
descriptor: {
|
|
72
|
-
value: queue(inst[methodName].bind(instance)) as unknown,
|
|
88
|
+
value: queue(inst[methodName].bind(instance), { signal: wantsSignal }) as unknown,
|
|
73
89
|
writable: true,
|
|
74
90
|
},
|
|
75
91
|
};
|
|
@@ -83,9 +99,10 @@ export function Pool(methodName: string, concurrency = 1) {
|
|
|
83
99
|
return createFieldDecorator({
|
|
84
100
|
bind(instance: object, _name: string) {
|
|
85
101
|
const inst = instance as Record<string, AnyAsyncFn>;
|
|
102
|
+
const wantsSignal = acceptsSignal(inst[methodName]);
|
|
86
103
|
return {
|
|
87
104
|
descriptor: {
|
|
88
|
-
value: pool(concurrency, inst[methodName].bind(instance)) as unknown,
|
|
105
|
+
value: pool(concurrency, inst[methodName].bind(instance), { signal: wantsSignal }) as unknown,
|
|
89
106
|
writable: true,
|
|
90
107
|
},
|
|
91
108
|
};
|
package/src/index.ts
CHANGED
package/src/pool.ts
CHANGED
|
@@ -1,49 +1,66 @@
|
|
|
1
1
|
import { signal, computed } from "@praxisjs/core/internal";
|
|
2
2
|
import type { Computed } from "@praxisjs/shared";
|
|
3
3
|
|
|
4
|
+
import { acceptsSignal } from "./utils";
|
|
5
|
+
|
|
4
6
|
export interface PoolInstance<T> {
|
|
5
7
|
(...args: unknown[]): Promise<T | undefined>;
|
|
6
8
|
loading: Computed<boolean>;
|
|
7
9
|
active: Computed<number>;
|
|
8
10
|
pending: Computed<number>;
|
|
9
11
|
error: Computed<Error | null>;
|
|
12
|
+
cancelAll(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isAbortError(err: unknown): boolean {
|
|
16
|
+
return err instanceof Error && err.name === "AbortError";
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
export function pool<T>(
|
|
13
20
|
concurrency: number,
|
|
14
21
|
fn: (...args: unknown[]) => Promise<T>,
|
|
22
|
+
options?: { signal?: boolean },
|
|
15
23
|
): PoolInstance<T> {
|
|
16
24
|
concurrency = Math.max(1, concurrency);
|
|
17
25
|
const _active = signal(0);
|
|
18
26
|
const _pending = signal(0);
|
|
19
27
|
const _error = signal<Error | null>(null);
|
|
28
|
+
const _wantsSignal = options?.signal ?? acceptsSignal(fn);
|
|
20
29
|
const _queue: Array<{
|
|
21
30
|
args: unknown[];
|
|
22
31
|
resolve: (v: T | undefined) => void;
|
|
32
|
+
controller: AbortController;
|
|
23
33
|
}> = [];
|
|
34
|
+
const _activeControllers = new Set<AbortController>();
|
|
24
35
|
|
|
25
36
|
async function tryRun(): Promise<void> {
|
|
26
37
|
if (_active() >= concurrency || _queue.length === 0) return;
|
|
27
38
|
const item = _queue[0];
|
|
28
39
|
_queue.shift();
|
|
29
40
|
|
|
30
|
-
const { args, resolve } = item;
|
|
41
|
+
const { args, resolve, controller } = item;
|
|
31
42
|
_pending.update((n) => n - 1);
|
|
32
43
|
_active.update((n) => n + 1);
|
|
44
|
+
_activeControllers.add(controller);
|
|
33
45
|
try {
|
|
34
|
-
|
|
46
|
+
const result = await (_wantsSignal ? fn(controller.signal, ...args) : fn(...args));
|
|
47
|
+
resolve(result);
|
|
35
48
|
} catch (err) {
|
|
36
|
-
|
|
49
|
+
if (!isAbortError(err)) {
|
|
50
|
+
_error.set(err instanceof Error ? err : new Error(String(err)));
|
|
51
|
+
}
|
|
37
52
|
resolve(undefined);
|
|
38
53
|
} finally {
|
|
54
|
+
_activeControllers.delete(controller);
|
|
39
55
|
_active.update((n) => n - 1);
|
|
40
56
|
void tryRun();
|
|
41
57
|
}
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
function run(...args: unknown[]): Promise<T | undefined> {
|
|
61
|
+
const controller = new AbortController();
|
|
45
62
|
return new Promise<T | undefined>((resolve) => {
|
|
46
|
-
_queue.push({ args, resolve });
|
|
63
|
+
_queue.push({ args, resolve, controller });
|
|
47
64
|
_pending.update((n) => n + 1);
|
|
48
65
|
void tryRun();
|
|
49
66
|
});
|
|
@@ -53,6 +70,18 @@ export function pool<T>(
|
|
|
53
70
|
run.active = computed(() => _active());
|
|
54
71
|
run.pending = computed(() => _pending());
|
|
55
72
|
run.error = computed(() => _error());
|
|
73
|
+
run.cancelAll = () => {
|
|
74
|
+
for (const c of _activeControllers) {
|
|
75
|
+
c.abort();
|
|
76
|
+
}
|
|
77
|
+
_activeControllers.clear();
|
|
78
|
+
while (_queue.length > 0) {
|
|
79
|
+
const item = _queue.shift();
|
|
80
|
+
item?.controller.abort();
|
|
81
|
+
item?.resolve(undefined);
|
|
82
|
+
}
|
|
83
|
+
_pending.set(0);
|
|
84
|
+
};
|
|
56
85
|
|
|
57
86
|
return run as PoolInstance<T>;
|
|
58
87
|
}
|
package/src/queue.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { signal, computed } from "@praxisjs/core/internal";
|
|
2
2
|
import type { Computed } from "@praxisjs/shared";
|
|
3
3
|
|
|
4
|
+
import { acceptsSignal } from "./utils";
|
|
5
|
+
|
|
4
6
|
export class QueueClearedError extends Error {
|
|
5
7
|
constructor() {
|
|
6
8
|
super("Queue cleared");
|
|
@@ -8,6 +10,10 @@ export class QueueClearedError extends Error {
|
|
|
8
10
|
}
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
function isAbortError(err: unknown): boolean {
|
|
14
|
+
return err instanceof Error && err.name === "AbortError";
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
export interface QueueInstance<T> {
|
|
12
18
|
(...args: unknown[]): Promise<T>;
|
|
13
19
|
loading: Computed<boolean>;
|
|
@@ -18,17 +24,22 @@ export interface QueueInstance<T> {
|
|
|
18
24
|
|
|
19
25
|
export function queue<T>(
|
|
20
26
|
fn: (...args: unknown[]) => Promise<T>,
|
|
27
|
+
options?: { signal?: boolean },
|
|
21
28
|
): QueueInstance<T> {
|
|
22
29
|
const _loading = signal(false);
|
|
23
30
|
const _pending = signal(0);
|
|
24
31
|
const _error = signal<Error | null>(null);
|
|
32
|
+
const _wantsSignal = options?.signal ?? acceptsSignal(fn);
|
|
25
33
|
const _queue: Array<{
|
|
26
34
|
args: unknown[];
|
|
27
35
|
resolve: (v: T) => void;
|
|
28
36
|
reject: (e: Error) => void;
|
|
37
|
+
controller: AbortController;
|
|
29
38
|
}> = [];
|
|
30
39
|
let _running = false;
|
|
31
40
|
let _cleared = false;
|
|
41
|
+
let _activeController: AbortController | null = null;
|
|
42
|
+
let _activeAbortedByClear = false;
|
|
32
43
|
|
|
33
44
|
async function drain(): Promise<void> {
|
|
34
45
|
if (_running || _queue.length === 0) return;
|
|
@@ -38,14 +49,25 @@ export function queue<T>(
|
|
|
38
49
|
const item = _queue[0];
|
|
39
50
|
_queue.shift();
|
|
40
51
|
|
|
41
|
-
const { args, resolve, reject } = item;
|
|
52
|
+
const { args, resolve, reject, controller } = item;
|
|
53
|
+
_activeController = controller;
|
|
54
|
+
_activeAbortedByClear = false;
|
|
42
55
|
_pending.update((n) => n - 1);
|
|
43
56
|
try {
|
|
44
|
-
|
|
57
|
+
const result = await (_wantsSignal ? fn(controller.signal, ...args) : fn(...args));
|
|
58
|
+
resolve(result);
|
|
45
59
|
} catch (err) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
// _activeAbortedByClear is set asynchronously by clear() between the await and this catch
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
62
|
+
if (isAbortError(err) && _activeAbortedByClear) {
|
|
63
|
+
reject(new QueueClearedError());
|
|
64
|
+
} else {
|
|
65
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
66
|
+
_error.set(e);
|
|
67
|
+
reject(e);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
_activeController = null;
|
|
49
71
|
}
|
|
50
72
|
}
|
|
51
73
|
_cleared = false;
|
|
@@ -57,8 +79,9 @@ export function queue<T>(
|
|
|
57
79
|
if (_cleared) {
|
|
58
80
|
_cleared = false;
|
|
59
81
|
}
|
|
82
|
+
const controller = new AbortController();
|
|
60
83
|
return new Promise<T>((resolve, reject) => {
|
|
61
|
-
_queue.push({ args, resolve, reject });
|
|
84
|
+
_queue.push({ args, resolve, reject, controller });
|
|
62
85
|
_pending.update((n) => n + 1);
|
|
63
86
|
void drain();
|
|
64
87
|
});
|
|
@@ -68,9 +91,14 @@ export function queue<T>(
|
|
|
68
91
|
enqueue.pending = computed(() => _pending());
|
|
69
92
|
enqueue.error = computed(() => _error());
|
|
70
93
|
enqueue.clear = () => {
|
|
71
|
-
|
|
94
|
+
if (_activeController) {
|
|
95
|
+
_activeAbortedByClear = true;
|
|
96
|
+
_activeController.abort();
|
|
97
|
+
}
|
|
72
98
|
while (_queue.length > 0) {
|
|
73
|
-
_queue.shift()
|
|
99
|
+
const item = _queue.shift();
|
|
100
|
+
item?.controller.abort();
|
|
101
|
+
item?.reject(new QueueClearedError());
|
|
74
102
|
}
|
|
75
103
|
_pending.set(0);
|
|
76
104
|
_cleared = true;
|
package/src/task.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { signal, computed } from "@praxisjs/core/internal";
|
|
2
2
|
import type { Computed } from "@praxisjs/shared";
|
|
3
3
|
|
|
4
|
+
import { acceptsSignal } from "./utils";
|
|
5
|
+
|
|
4
6
|
export interface TaskInstance<T> {
|
|
5
7
|
(...args: unknown[]): Promise<T | undefined>;
|
|
6
8
|
loading: Computed<boolean>;
|
|
@@ -9,27 +11,39 @@ export interface TaskInstance<T> {
|
|
|
9
11
|
cancelAll(): void;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
function isAbortError(err: unknown): boolean {
|
|
15
|
+
return err instanceof Error && err.name === "AbortError";
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
export function task<T>(
|
|
13
19
|
fn: (...args: unknown[]) => Promise<T>,
|
|
20
|
+
options?: { signal?: boolean },
|
|
14
21
|
): TaskInstance<T> {
|
|
15
22
|
const _loading = signal(false);
|
|
16
23
|
const _error = signal<Error | null>(null);
|
|
17
24
|
const _lastResult = signal<T | null>(null);
|
|
18
25
|
let _runId = 0;
|
|
26
|
+
let _controller: AbortController | null = null;
|
|
27
|
+
const _wantsSignal = options?.signal ?? acceptsSignal(fn);
|
|
19
28
|
|
|
20
29
|
async function run(...args: unknown[]): Promise<T | undefined> {
|
|
30
|
+
_controller?.abort();
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
_controller = controller;
|
|
21
33
|
const id = ++_runId;
|
|
22
34
|
_loading.set(true);
|
|
23
35
|
_error.set(null);
|
|
24
36
|
try {
|
|
25
|
-
const result = await fn(...args);
|
|
37
|
+
const result = await (_wantsSignal ? fn(controller.signal, ...args) : fn(...args));
|
|
26
38
|
if (id !== _runId) return undefined;
|
|
27
39
|
_lastResult.set(result);
|
|
28
40
|
_loading.set(false);
|
|
29
41
|
return result;
|
|
30
42
|
} catch (err) {
|
|
31
43
|
if (id !== _runId) return undefined;
|
|
32
|
-
|
|
44
|
+
if (!isAbortError(err)) {
|
|
45
|
+
_error.set(err instanceof Error ? err : new Error(String(err)));
|
|
46
|
+
}
|
|
33
47
|
_loading.set(false);
|
|
34
48
|
return undefined;
|
|
35
49
|
}
|
|
@@ -39,6 +53,8 @@ export function task<T>(
|
|
|
39
53
|
run.error = computed(() => _error());
|
|
40
54
|
run.lastResult = computed(() => _lastResult());
|
|
41
55
|
run.cancelAll = () => {
|
|
56
|
+
_controller?.abort();
|
|
57
|
+
_controller = null;
|
|
42
58
|
_runId++;
|
|
43
59
|
_loading.set(false);
|
|
44
60
|
};
|