@navaramap/worker 0.2.1 → 0.3.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/dist/index.js +708 -682
- package/dist/index.umd.cjs +2 -2
- package/dist/manager.d.ts +11 -1
- package/dist/pool.d.ts +11 -3
- package/dist/recyclingPool.d.ts +15 -0
- package/package.json +4 -4
- package/src/helpers/transferConstructedPolylineGeometry.ts +5 -3
- package/src/helpers/transferReturnedConstructedTerrainMesh.ts +1 -0
- package/src/manager.test.ts +5 -1
- package/src/manager.ts +18 -5
- package/src/pool.test.ts +9 -2
- package/src/pool.ts +107 -29
- package/src/poolLease.test.ts +152 -0
- package/src/recyclingPool.test.ts +104 -2
- package/src/recyclingPool.ts +101 -8
package/src/pool.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import workerpool from "workerpool";
|
|
|
3
3
|
import type Pool from "workerpool/types/Pool";
|
|
4
4
|
|
|
5
5
|
import { createConcurrencyManager } from "./manager";
|
|
6
|
-
import {
|
|
6
|
+
import { acquireWorkerPool, queueTask } from "./pool";
|
|
7
7
|
|
|
8
8
|
const workPoolExecStates = (() => {
|
|
9
9
|
const states = {
|
|
@@ -39,13 +39,20 @@ vi.spyOn(workerpool, "pool").mockImplementation(
|
|
|
39
39
|
);
|
|
40
40
|
},
|
|
41
41
|
terminate: () => undefined,
|
|
42
|
+
stats: () => ({
|
|
43
|
+
totalWorkers: 1,
|
|
44
|
+
busyWorkers: 0,
|
|
45
|
+
idleWorkers: 1,
|
|
46
|
+
pendingTasks: 0,
|
|
47
|
+
activeTasks: 0,
|
|
48
|
+
}),
|
|
42
49
|
}) as unknown as Pool,
|
|
43
50
|
);
|
|
44
51
|
|
|
45
52
|
// Worker pool is initialized globally.
|
|
46
53
|
const manager = createConcurrencyManager(1);
|
|
47
54
|
const incrementFnMock = vi.spyOn(manager, "increment");
|
|
48
|
-
|
|
55
|
+
acquireWorkerPool("https://example.com", manager);
|
|
49
56
|
|
|
50
57
|
beforeEach(() => {
|
|
51
58
|
workPoolExecStates.reset();
|
package/src/pool.ts
CHANGED
|
@@ -16,10 +16,27 @@ export type {
|
|
|
16
16
|
WorkerPoolStats,
|
|
17
17
|
} from "./recyclingPool";
|
|
18
18
|
|
|
19
|
+
/** One holder's claim on the shared worker pool; see `acquireWorkerPool`. */
|
|
20
|
+
export type WorkerPoolLease = {
|
|
21
|
+
/** The pool's concurrency manager, which may be another holder's. */
|
|
22
|
+
manager: ConcurrencyManager;
|
|
23
|
+
/** Whether this claim created the pool, so its holder warms it up. */
|
|
24
|
+
created: boolean;
|
|
25
|
+
/** Drops this claim; the last release terminates the pool. Idempotent. */
|
|
26
|
+
release: () => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const NOT_ACQUIRED =
|
|
30
|
+
"The worker pool is not acquired: call acquireWorkerPool() first, and do not use the pool after its last holder released it.";
|
|
31
|
+
|
|
19
32
|
const {
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
acquireWorkerPool,
|
|
34
|
+
workerPoolHolders,
|
|
22
35
|
warmUpWorkerPool,
|
|
36
|
+
canWorkerPoolAcceptTasks,
|
|
37
|
+
workerPoolLoad,
|
|
38
|
+
reserveWorkerCore,
|
|
39
|
+
workerPoolReservedSlots,
|
|
23
40
|
worker,
|
|
24
41
|
workerPoolStats,
|
|
25
42
|
workerPoolHeapStats,
|
|
@@ -30,43 +47,105 @@ const {
|
|
|
30
47
|
| {
|
|
31
48
|
pool: RecyclingWorkerPool;
|
|
32
49
|
manager: ConcurrencyManager;
|
|
50
|
+
maxWorkerHeapBytes: number | undefined;
|
|
33
51
|
}
|
|
34
52
|
| undefined;
|
|
53
|
+
/** Cores held by threads outside the pool; outlives the pool itself, so a
|
|
54
|
+
* reservation taken before initialization (or across a re-init) holds. */
|
|
55
|
+
let coreReservations = 0;
|
|
56
|
+
let holders = 0;
|
|
35
57
|
|
|
36
58
|
return {
|
|
37
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Claims the page-wide worker pool, creating it on the first claim. Every
|
|
61
|
+
* view shares the one pool so their tasks contend for the same cores;
|
|
62
|
+
* `url`, `manager` and `options` of later claims are ignored while the
|
|
63
|
+
* pool lives (a differing pool size or heap budget logs a warning).
|
|
64
|
+
*/
|
|
65
|
+
acquireWorkerPool: (
|
|
38
66
|
url: string,
|
|
39
67
|
manager: ConcurrencyManager,
|
|
40
68
|
options?: RecyclingWorkerPoolOptions,
|
|
41
|
-
) => {
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
): WorkerPoolLease => {
|
|
70
|
+
const created = !worker;
|
|
71
|
+
if (!worker) {
|
|
72
|
+
worker = {
|
|
73
|
+
// Recycles worker threads to bound WASM linear memory growth; see
|
|
74
|
+
// recyclingPool.ts for the policy.
|
|
75
|
+
pool: new RecyclingWorkerPool(url, manager.total, options),
|
|
76
|
+
manager,
|
|
77
|
+
maxWorkerHeapBytes: options?.maxWorkerHeapBytes,
|
|
78
|
+
};
|
|
79
|
+
worker.pool.setReservedSlots(coreReservations);
|
|
80
|
+
} else if (
|
|
81
|
+
manager.total !== worker.manager.total ||
|
|
82
|
+
options?.maxWorkerHeapBytes !== worker.maxWorkerHeapBytes
|
|
83
|
+
) {
|
|
84
|
+
console.warn(
|
|
85
|
+
`The shared worker pool keeps its size (${worker.manager.total}) and worker heap budget (${worker.maxWorkerHeapBytes ?? "default"}); this claim asked for ${manager.total} and ${options?.maxWorkerHeapBytes ?? "default"}.`,
|
|
86
|
+
);
|
|
44
87
|
}
|
|
88
|
+
holders++;
|
|
45
89
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
90
|
+
let released = false;
|
|
91
|
+
return {
|
|
92
|
+
manager: worker.manager,
|
|
93
|
+
created,
|
|
94
|
+
release: () => {
|
|
95
|
+
if (released) return;
|
|
96
|
+
released = true;
|
|
97
|
+
holders--;
|
|
98
|
+
if (holders === 0 && worker) {
|
|
99
|
+
worker.pool.terminate();
|
|
100
|
+
worker = undefined;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
51
103
|
};
|
|
52
104
|
},
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
worker.pool.terminate();
|
|
56
|
-
worker = undefined;
|
|
57
|
-
}
|
|
58
|
-
},
|
|
105
|
+
/** Live claims on the pool; see `acquireWorkerPool`. */
|
|
106
|
+
workerPoolHolders: () => holders,
|
|
59
107
|
/**
|
|
60
108
|
* Pre-initializes WASM on the workers so that upcoming tasks don't pay
|
|
61
109
|
* the initialization latency. Warms a single worker and resolves once
|
|
62
110
|
* it is ready; the rest warm in the background.
|
|
63
111
|
*/
|
|
64
112
|
warmUpWorkerPool: () => {
|
|
65
|
-
invariant(worker,
|
|
113
|
+
invariant(worker, NOT_ACQUIRED);
|
|
66
114
|
return worker.pool.warmUp();
|
|
67
115
|
},
|
|
116
|
+
/**
|
|
117
|
+
* Whether at least one worker is under its WASM heap budget. While false
|
|
118
|
+
* the engine's worker-task intake must pause so the draining workers
|
|
119
|
+
* reach a task boundary and recycle.
|
|
120
|
+
*/
|
|
121
|
+
canWorkerPoolAcceptTasks: () => worker?.pool.canAcceptTasks() ?? true,
|
|
122
|
+
/**
|
|
123
|
+
* Tasks the pool's workers have running or queued, including tasks
|
|
124
|
+
* cancelled while running; see RecyclingWorkerPool.load.
|
|
125
|
+
*/
|
|
126
|
+
workerPoolLoad: () => worker?.pool.load() ?? 0,
|
|
127
|
+
/**
|
|
128
|
+
* Hold one core for a thread outside the pool (a font worker, a splat
|
|
129
|
+
* decoder) while it works: one pool slot stops taking tasks and its
|
|
130
|
+
* worker idles once its queued tasks finish. Returns the release
|
|
131
|
+
* function; calling it again is a no-op. At least one slot always keeps
|
|
132
|
+
* taking tasks.
|
|
133
|
+
*/
|
|
134
|
+
reserveWorkerCore: (): (() => void) => {
|
|
135
|
+
coreReservations++;
|
|
136
|
+
worker?.pool.setReservedSlots(coreReservations);
|
|
137
|
+
let released = false;
|
|
138
|
+
return () => {
|
|
139
|
+
if (released) return;
|
|
140
|
+
released = true;
|
|
141
|
+
coreReservations--;
|
|
142
|
+
worker?.pool.setReservedSlots(coreReservations);
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
/** Pool slots kept free for threads outside the pool right now. */
|
|
146
|
+
workerPoolReservedSlots: () => worker?.pool.reservedSlots() ?? 0,
|
|
68
147
|
worker: () => {
|
|
69
|
-
invariant(worker,
|
|
148
|
+
invariant(worker, NOT_ACQUIRED);
|
|
70
149
|
return {
|
|
71
150
|
pool: worker.pool,
|
|
72
151
|
manager: worker.manager,
|
|
@@ -81,21 +160,18 @@ const {
|
|
|
81
160
|
})();
|
|
82
161
|
|
|
83
162
|
export {
|
|
84
|
-
|
|
85
|
-
|
|
163
|
+
acquireWorkerPool,
|
|
164
|
+
workerPoolHolders,
|
|
86
165
|
warmUpWorkerPool,
|
|
166
|
+
canWorkerPoolAcceptTasks,
|
|
167
|
+
workerPoolLoad,
|
|
168
|
+
reserveWorkerCore,
|
|
169
|
+
workerPoolReservedSlots,
|
|
87
170
|
workerPoolStats,
|
|
88
171
|
workerPoolHeapStats,
|
|
89
172
|
probeWorkerPoolHeap,
|
|
90
173
|
};
|
|
91
174
|
|
|
92
|
-
export const canWorkerProcessImmediately = () => {
|
|
93
|
-
const { pool, manager } = worker();
|
|
94
|
-
// Also pauses intake while every worker is over its WASM heap budget
|
|
95
|
-
// (draining); a recycled worker restores capacity moments later.
|
|
96
|
-
return manager.canIncrement() && pool.canAcceptTasks();
|
|
97
|
-
};
|
|
98
|
-
|
|
99
175
|
export type { ExecOptions } from "workerpool/types/types";
|
|
100
176
|
|
|
101
177
|
type GetTaskName<Task> =
|
|
@@ -136,7 +212,9 @@ export function queueTask<
|
|
|
136
212
|
// `finally` doesn't work, so use `then()` and `catch()` to ensure that `manager.decrement()` is invoked.
|
|
137
213
|
.then((p) => {
|
|
138
214
|
manager.decrement();
|
|
139
|
-
return
|
|
215
|
+
// workerpool types the result as the task's raw return type, but it
|
|
216
|
+
// resolves with the awaited value.
|
|
217
|
+
return p as MethodReturnType<Task, Name>;
|
|
140
218
|
})
|
|
141
219
|
.catch((e) => {
|
|
142
220
|
manager.decrement();
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createConcurrencyManager, type ConcurrencyManager } from "./manager";
|
|
4
|
+
import {
|
|
5
|
+
acquireWorkerPool as acquireWorkerPoolRaw,
|
|
6
|
+
warmUpWorkerPool,
|
|
7
|
+
workerPoolHolders,
|
|
8
|
+
type WorkerPoolLease,
|
|
9
|
+
} from "./pool";
|
|
10
|
+
import type { RecyclingWorkerPoolOptions } from "./recyclingPool";
|
|
11
|
+
|
|
12
|
+
const { created, terminated } = vi.hoisted(() => ({
|
|
13
|
+
created: { count: 0 },
|
|
14
|
+
terminated: { count: 0 },
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./recyclingPool", () => ({
|
|
18
|
+
RecyclingWorkerPool: class {
|
|
19
|
+
constructor() {
|
|
20
|
+
created.count++;
|
|
21
|
+
}
|
|
22
|
+
setReservedSlots() {}
|
|
23
|
+
warmUp() {
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
terminate() {
|
|
27
|
+
terminated.count++;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Every lease a test takes, released after it so a failed assertion cannot
|
|
33
|
+
// leave the module-level pool alive for the next test.
|
|
34
|
+
const leases: WorkerPoolLease[] = [];
|
|
35
|
+
function acquireWorkerPool(
|
|
36
|
+
url: string,
|
|
37
|
+
manager: ConcurrencyManager,
|
|
38
|
+
options?: RecyclingWorkerPoolOptions,
|
|
39
|
+
) {
|
|
40
|
+
const lease = acquireWorkerPoolRaw(url, manager, options);
|
|
41
|
+
leases.push(lease);
|
|
42
|
+
return lease;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
created.count = 0;
|
|
47
|
+
terminated.count = 0;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
for (const lease of leases.splice(0)) lease.release();
|
|
52
|
+
vi.restoreAllMocks();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("shares one pool and its manager between holders", () => {
|
|
56
|
+
const first = createConcurrencyManager(4);
|
|
57
|
+
const a = acquireWorkerPool("https://example.com", first);
|
|
58
|
+
const b = acquireWorkerPool(
|
|
59
|
+
"https://example.com",
|
|
60
|
+
createConcurrencyManager(2),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(created.count).toBe(1);
|
|
64
|
+
expect(a.manager).toBe(first);
|
|
65
|
+
expect(b.manager).toBe(first);
|
|
66
|
+
|
|
67
|
+
a.release();
|
|
68
|
+
b.release();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("terminates the pool only when the last holder releases", () => {
|
|
72
|
+
const a = acquireWorkerPool(
|
|
73
|
+
"https://example.com",
|
|
74
|
+
createConcurrencyManager(1),
|
|
75
|
+
);
|
|
76
|
+
const b = acquireWorkerPool(
|
|
77
|
+
"https://example.com",
|
|
78
|
+
createConcurrencyManager(1),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
a.release();
|
|
82
|
+
expect(terminated.count).toBe(0);
|
|
83
|
+
expect(() => warmUpWorkerPool()).not.toThrow();
|
|
84
|
+
|
|
85
|
+
b.release();
|
|
86
|
+
expect(terminated.count).toBe(1);
|
|
87
|
+
expect(() => warmUpWorkerPool()).toThrow();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("ignores a repeated release so it cannot drop another holder's claim", () => {
|
|
91
|
+
const a = acquireWorkerPool(
|
|
92
|
+
"https://example.com",
|
|
93
|
+
createConcurrencyManager(1),
|
|
94
|
+
);
|
|
95
|
+
const b = acquireWorkerPool(
|
|
96
|
+
"https://example.com",
|
|
97
|
+
createConcurrencyManager(1),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
a.release();
|
|
101
|
+
a.release();
|
|
102
|
+
expect(terminated.count).toBe(0);
|
|
103
|
+
|
|
104
|
+
b.release();
|
|
105
|
+
expect(terminated.count).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("creates a fresh pool after every holder released", () => {
|
|
109
|
+
acquireWorkerPool(
|
|
110
|
+
"https://example.com",
|
|
111
|
+
createConcurrencyManager(1),
|
|
112
|
+
).release();
|
|
113
|
+
const next = createConcurrencyManager(3);
|
|
114
|
+
const lease = acquireWorkerPool("https://example.com", next);
|
|
115
|
+
|
|
116
|
+
expect(created.count).toBe(2);
|
|
117
|
+
expect(lease.manager).toBe(next);
|
|
118
|
+
|
|
119
|
+
lease.release();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("tells only the creating claim that it created the pool", () => {
|
|
123
|
+
const a = acquireWorkerPool(
|
|
124
|
+
"https://example.com",
|
|
125
|
+
createConcurrencyManager(1),
|
|
126
|
+
);
|
|
127
|
+
const b = acquireWorkerPool(
|
|
128
|
+
"https://example.com",
|
|
129
|
+
createConcurrencyManager(1),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(a.created).toBe(true);
|
|
133
|
+
expect(b.created).toBe(false);
|
|
134
|
+
expect(workerPoolHolders()).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("warns when a later claim asks for different pool settings", () => {
|
|
138
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
139
|
+
acquireWorkerPool("https://example.com", createConcurrencyManager(4), {
|
|
140
|
+
maxWorkerHeapBytes: 256,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
acquireWorkerPool("https://example.com", createConcurrencyManager(4), {
|
|
144
|
+
maxWorkerHeapBytes: 256,
|
|
145
|
+
});
|
|
146
|
+
expect(warn).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
acquireWorkerPool("https://example.com", createConcurrencyManager(2), {
|
|
149
|
+
maxWorkerHeapBytes: 128,
|
|
150
|
+
});
|
|
151
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
152
|
+
});
|
|
@@ -17,6 +17,10 @@ type MockExecution = {
|
|
|
17
17
|
|
|
18
18
|
type MockPool = {
|
|
19
19
|
executions: MockExecution[];
|
|
20
|
+
/** Busy / queued counts the worker reports, independent of the slot's
|
|
21
|
+
* own `inflight` (e.g. a cancelled task still running). */
|
|
22
|
+
activeTasks: number;
|
|
23
|
+
pendingTasks: number;
|
|
20
24
|
exec: ReturnType<typeof vi.fn>;
|
|
21
25
|
terminate: ReturnType<typeof vi.fn>;
|
|
22
26
|
stats: () => ReturnType<Pool["stats"]>;
|
|
@@ -29,6 +33,8 @@ let pools: MockPool[] = [];
|
|
|
29
33
|
vi.spyOn(workerpool, "pool").mockImplementation(() => {
|
|
30
34
|
const mock: MockPool = {
|
|
31
35
|
executions: [],
|
|
36
|
+
activeTasks: 0,
|
|
37
|
+
pendingTasks: 0,
|
|
32
38
|
exec: vi.fn(
|
|
33
39
|
(method: string) =>
|
|
34
40
|
new workerpool.Promise(
|
|
@@ -57,8 +63,8 @@ vi.spyOn(workerpool, "pool").mockImplementation(() => {
|
|
|
57
63
|
totalWorkers: 1,
|
|
58
64
|
busyWorkers: 0,
|
|
59
65
|
idleWorkers: 1,
|
|
60
|
-
pendingTasks:
|
|
61
|
-
activeTasks:
|
|
66
|
+
pendingTasks: mock.pendingTasks,
|
|
67
|
+
activeTasks: mock.activeTasks,
|
|
62
68
|
}),
|
|
63
69
|
};
|
|
64
70
|
pools.push(mock);
|
|
@@ -115,6 +121,91 @@ it("dispatches tasks to the least-loaded slot", () => {
|
|
|
115
121
|
expect(pools[1].exec).toHaveBeenCalledTimes(1);
|
|
116
122
|
});
|
|
117
123
|
|
|
124
|
+
it("does not dispatch to a worker still running a cancelled task", async () => {
|
|
125
|
+
const pool = new RecyclingWorkerPool("https://example.com", 2);
|
|
126
|
+
|
|
127
|
+
const cancelled = pool.exec("test");
|
|
128
|
+
cancelled.cancel();
|
|
129
|
+
await cancelled.catch(() => undefined);
|
|
130
|
+
respondToProbe(pools[0], 0);
|
|
131
|
+
// The slot's own count is back to 0, but its worker is still busy.
|
|
132
|
+
pools[0].activeTasks = 1;
|
|
133
|
+
|
|
134
|
+
pool.exec("test");
|
|
135
|
+
const tests = (p: MockPool) =>
|
|
136
|
+
p.executions.filter((e) => e.method === "test").length;
|
|
137
|
+
expect(tests(pools[0])).toBe(1);
|
|
138
|
+
expect(tests(pools[1])).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("sums the slots' load, counting a cancelled task still running", async () => {
|
|
142
|
+
const pool = new RecyclingWorkerPool("https://example.com", 2);
|
|
143
|
+
expect(pool.load()).toBe(0);
|
|
144
|
+
|
|
145
|
+
const cancelled = pool.exec("test");
|
|
146
|
+
pool.exec("test");
|
|
147
|
+
expect(pool.load()).toBe(2);
|
|
148
|
+
|
|
149
|
+
cancelled.cancel();
|
|
150
|
+
await cancelled.catch(() => undefined);
|
|
151
|
+
respondToProbe(pools[0], 0);
|
|
152
|
+
expect(pool.load()).toBe(1);
|
|
153
|
+
// Its worker is still running it.
|
|
154
|
+
pools[0].activeTasks = 1;
|
|
155
|
+
expect(pool.load()).toBe(2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("leaves the pool's own probes out of its load", async () => {
|
|
159
|
+
const pool = new RecyclingWorkerPool("https://example.com", 1);
|
|
160
|
+
|
|
161
|
+
pool.exec("test");
|
|
162
|
+
settleTask(pools[0]);
|
|
163
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
164
|
+
expect(nextPending(pools[0], PROBE)).toBeDefined();
|
|
165
|
+
expect(pool.load()).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps reserved slots free of new tasks, leaving one slot open", () => {
|
|
169
|
+
const pool = new RecyclingWorkerPool("https://example.com", 3);
|
|
170
|
+
const tests = (p: MockPool) =>
|
|
171
|
+
p.executions.filter((e) => e.method === "test").length;
|
|
172
|
+
|
|
173
|
+
// Slot 0 is busy, so the idle slots are the ones reserved.
|
|
174
|
+
pool.exec("test");
|
|
175
|
+
pool.setReservedSlots(2);
|
|
176
|
+
expect(pool.reservedSlots()).toBe(2);
|
|
177
|
+
pool.exec("test");
|
|
178
|
+
pool.exec("test");
|
|
179
|
+
expect(tests(pools[0])).toBe(3);
|
|
180
|
+
expect(tests(pools[1])).toBe(0);
|
|
181
|
+
expect(tests(pools[2])).toBe(0);
|
|
182
|
+
|
|
183
|
+
pool.setReservedSlots(5);
|
|
184
|
+
expect(pool.reservedSlots()).toBe(2);
|
|
185
|
+
|
|
186
|
+
pool.setReservedSlots(0);
|
|
187
|
+
expect(pool.reservedSlots()).toBe(0);
|
|
188
|
+
pool.exec("test");
|
|
189
|
+
expect(tests(pools[1]) + tests(pools[2])).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("reports no capacity while every unreserved slot is draining", async () => {
|
|
193
|
+
const pool = new RecyclingWorkerPool("https://example.com", 2, {
|
|
194
|
+
maxWorkerHeapBytes: 100,
|
|
195
|
+
});
|
|
196
|
+
pool.exec("test");
|
|
197
|
+
pool.setReservedSlots(1);
|
|
198
|
+
expect(pool.canAcceptTasks()).toBe(true);
|
|
199
|
+
|
|
200
|
+
// The open slot goes over budget while its next task still runs.
|
|
201
|
+
pool.exec("test");
|
|
202
|
+
settleTask(pools[0]);
|
|
203
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
204
|
+
respondToProbe(pools[0], 200);
|
|
205
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
206
|
+
expect(pool.canAcceptTasks()).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
118
209
|
it("probes heap usage after a settled task and recycles once over budget", async () => {
|
|
119
210
|
const pool = new RecyclingWorkerPool("https://example.com", 1, {
|
|
120
211
|
maxWorkerHeapBytes: 100,
|
|
@@ -262,6 +353,17 @@ it("warmUp() warms a single worker first, then the rest in the background", asyn
|
|
|
262
353
|
expect(nextPending(pools[1], "warmUp")).toBeDefined();
|
|
263
354
|
});
|
|
264
355
|
|
|
356
|
+
it("warmUp() starts no background warm-up once terminated", async () => {
|
|
357
|
+
const pool = new RecyclingWorkerPool("https://example.com", 2);
|
|
358
|
+
|
|
359
|
+
const warmed = pool.warmUp();
|
|
360
|
+
pool.terminate();
|
|
361
|
+
nextPending(pools[0], "warmUp")?.resolve();
|
|
362
|
+
await warmed;
|
|
363
|
+
|
|
364
|
+
expect(pools[1].executions).toHaveLength(0);
|
|
365
|
+
});
|
|
366
|
+
|
|
265
367
|
it("warmUp() falls back to the remaining workers when the first fails", async () => {
|
|
266
368
|
const pool = new RecyclingWorkerPool("https://example.com", 2);
|
|
267
369
|
|
package/src/recyclingPool.ts
CHANGED
|
@@ -18,8 +18,9 @@ import type { ExecOptions } from "workerpool/types/types";
|
|
|
18
18
|
// bounded even under sustained load (no pool-wide idle moment is required)
|
|
19
19
|
// and never discards more than one warm WASM instance at a time. The
|
|
20
20
|
// trade-off versus a single shared queue is possible head-of-line blocking
|
|
21
|
-
// within a slot, but upstream backpressure
|
|
22
|
-
// per
|
|
21
|
+
// within a slot, but upstream backpressure keeps per-slot queues shallow:
|
|
22
|
+
// the engine admits only about two tasks per worker, and its intake pauses
|
|
23
|
+
// while every slot is draining (see canAcceptTasks).
|
|
23
24
|
//
|
|
24
25
|
// A burst that stays under the heap budget would otherwise keep its heaps
|
|
25
26
|
// forever, so an idle sweep additionally recycles every used slot once the
|
|
@@ -62,6 +63,12 @@ type Slot = {
|
|
|
62
63
|
tasksSinceSpawn: number;
|
|
63
64
|
/** Dispatched but not yet settled tasks, including warm-ups and probes. */
|
|
64
65
|
inflight: number;
|
|
66
|
+
/** Warm-ups and probes in flight (the pool's own traffic, part of
|
|
67
|
+
* `inflight`). */
|
|
68
|
+
internal: number;
|
|
69
|
+
/** Kept free for a thread outside the pool: receives no new task, so its
|
|
70
|
+
* worker idles once the tasks it has finish (see setReservedSlots). */
|
|
71
|
+
reserved: boolean;
|
|
65
72
|
/** Set once the slot exceeds its heap budget; drained slots recycle. */
|
|
66
73
|
draining: boolean;
|
|
67
74
|
/** Whether a heap probe is already in flight (probes are coalesced). */
|
|
@@ -84,6 +91,19 @@ export type WorkerPoolHeapStats = {
|
|
|
84
91
|
maxWorkerHeapBytes: number;
|
|
85
92
|
};
|
|
86
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Tasks a slot's worker still has ahead of a new one. `inflight` drops as
|
|
96
|
+
* soon as a task is cancelled, but a task cancelled while running keeps the
|
|
97
|
+
* worker busy until it finishes (the worker-side abort listener lets it run
|
|
98
|
+
* to completion), so the worker's own busy/queued counts are consulted too.
|
|
99
|
+
* Cancelled tasks still queued are skipped instantly once the worker frees;
|
|
100
|
+
* counting them only steers new work away from a busy worker.
|
|
101
|
+
*/
|
|
102
|
+
const slotLoad = (slot: Slot): number => {
|
|
103
|
+
const { activeTasks, pendingTasks } = slot.pool.stats();
|
|
104
|
+
return Math.max(slot.inflight, activeTasks + pendingTasks);
|
|
105
|
+
};
|
|
106
|
+
|
|
87
107
|
const createPool = (url: string) =>
|
|
88
108
|
workerpool.pool(url, {
|
|
89
109
|
// One worker per pool so each worker can be recycled independently.
|
|
@@ -116,6 +136,8 @@ export class RecyclingWorkerPool {
|
|
|
116
136
|
pool: createPool(url),
|
|
117
137
|
tasksSinceSpawn: 0,
|
|
118
138
|
inflight: 0,
|
|
139
|
+
internal: 0,
|
|
140
|
+
reserved: false,
|
|
119
141
|
draining: false,
|
|
120
142
|
probing: false,
|
|
121
143
|
lastHeapBytes: undefined,
|
|
@@ -189,7 +211,62 @@ export class RecyclingWorkerPool {
|
|
|
189
211
|
* least-loaded draining slot).
|
|
190
212
|
*/
|
|
191
213
|
canAcceptTasks(): boolean {
|
|
192
|
-
return this.slots.some((slot) => !slot.draining);
|
|
214
|
+
return this.slots.some((slot) => !slot.draining && !slot.reserved);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Keep `count` slots free for threads outside the pool (clamped so one
|
|
219
|
+
* slot always takes tasks): a reserved slot receives no new task, so its
|
|
220
|
+
* worker idles and leaves its core to them once its queued tasks finish.
|
|
221
|
+
* Newly reserved slots are the least loaded ones, which free soonest.
|
|
222
|
+
*/
|
|
223
|
+
setReservedSlots(count: number): void {
|
|
224
|
+
const target = Math.max(0, Math.min(count, this.slots.length - 1));
|
|
225
|
+
let reserved = this.reservedSlots();
|
|
226
|
+
while (reserved < target) {
|
|
227
|
+
let least: Slot | undefined;
|
|
228
|
+
let leastLoad = Infinity;
|
|
229
|
+
for (const slot of this.slots) {
|
|
230
|
+
if (slot.reserved) continue;
|
|
231
|
+
const load = slotLoad(slot);
|
|
232
|
+
if (load < leastLoad) {
|
|
233
|
+
least = slot;
|
|
234
|
+
leastLoad = load;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!least) break;
|
|
238
|
+
least.reserved = true;
|
|
239
|
+
reserved++;
|
|
240
|
+
}
|
|
241
|
+
for (const slot of this.slots) {
|
|
242
|
+
if (reserved <= target) break;
|
|
243
|
+
if (slot.reserved) {
|
|
244
|
+
slot.reserved = false;
|
|
245
|
+
reserved--;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Slots currently kept free (see setReservedSlots). */
|
|
251
|
+
reservedSlots(): number {
|
|
252
|
+
let reserved = 0;
|
|
253
|
+
for (const slot of this.slots) {
|
|
254
|
+
if (slot.reserved) reserved++;
|
|
255
|
+
}
|
|
256
|
+
return reserved;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Tasks handed to the pool that the workers still have, summed over the
|
|
261
|
+
* slots: running and queued, including tasks cancelled while running (see
|
|
262
|
+
* slotLoad). The pool's own warm-ups and probes are left out.
|
|
263
|
+
*/
|
|
264
|
+
load(): number {
|
|
265
|
+
let load = 0;
|
|
266
|
+
for (const slot of this.slots) {
|
|
267
|
+
load += Math.max(0, slotLoad(slot) - slot.internal);
|
|
268
|
+
}
|
|
269
|
+
return load;
|
|
193
270
|
}
|
|
194
271
|
|
|
195
272
|
stats(): WorkerPoolStats {
|
|
@@ -253,14 +330,23 @@ export class RecyclingWorkerPool {
|
|
|
253
330
|
}
|
|
254
331
|
|
|
255
332
|
private pickSlot(): Slot {
|
|
333
|
+
// Reserved slots never take a task (one slot always stays unreserved).
|
|
256
334
|
// Prefer slots still under their heap budget; fall back to a draining
|
|
257
335
|
// slot only when every slot is draining at once (it recycles once it
|
|
258
336
|
// finally drains, so this cannot starve the recycling).
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
);
|
|
337
|
+
const open = this.slots.filter((slot) => !slot.reserved);
|
|
338
|
+
const candidates = open.filter((slot) => !slot.draining);
|
|
339
|
+
const eligible = candidates.length > 0 ? candidates : open;
|
|
340
|
+
let least = eligible[0];
|
|
341
|
+
let leastLoad = slotLoad(least);
|
|
342
|
+
for (const slot of eligible) {
|
|
343
|
+
const load = slotLoad(slot);
|
|
344
|
+
if (load < leastLoad) {
|
|
345
|
+
least = slot;
|
|
346
|
+
leastLoad = load;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return least;
|
|
264
350
|
}
|
|
265
351
|
|
|
266
352
|
private onSettled(slot: Slot): void {
|
|
@@ -297,9 +383,14 @@ export class RecyclingWorkerPool {
|
|
|
297
383
|
// perpetuate itself through the work it triggers.
|
|
298
384
|
|
|
299
385
|
private warmSlot(slot: Slot): Promise<void> {
|
|
386
|
+
// A terminated workerpool still spawns a worker on exec, and the
|
|
387
|
+
// background warm-ups can start after terminate().
|
|
388
|
+
if (this.terminated) return Promise.resolve();
|
|
300
389
|
slot.inflight++;
|
|
390
|
+
slot.internal++;
|
|
301
391
|
const settle = () => {
|
|
302
392
|
slot.inflight--;
|
|
393
|
+
slot.internal--;
|
|
303
394
|
// Same as the probe: if the slot drained while this warm-up was in
|
|
304
395
|
// flight, its settlement is the last task boundary, so recycle here.
|
|
305
396
|
if (!this.terminated && slot.draining && slot.inflight === 0) {
|
|
@@ -336,9 +427,11 @@ export class RecyclingWorkerPool {
|
|
|
336
427
|
if (slot.probing) return;
|
|
337
428
|
slot.probing = true;
|
|
338
429
|
slot.inflight++;
|
|
430
|
+
slot.internal++;
|
|
339
431
|
const settle = (overBudget: boolean) => {
|
|
340
432
|
slot.probing = false;
|
|
341
433
|
slot.inflight--;
|
|
434
|
+
slot.internal--;
|
|
342
435
|
if (this.terminated) return;
|
|
343
436
|
if (overBudget) {
|
|
344
437
|
slot.draining = true;
|