nestworker 2.1.5 → 2.1.7
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 +684 -611
- package/dist/core/worker.interfaces.d.ts +38 -3
- package/dist/core/worker.module.js +1 -5
- package/dist/core/worker.module.js.map +1 -1
- package/dist/core/worker.pool.d.ts +52 -15
- package/dist/core/worker.pool.js +399 -223
- package/dist/core/worker.pool.js.map +1 -1
- package/dist/core/worker.service.d.ts +37 -3
- package/dist/core/worker.service.js +93 -18
- package/dist/core/worker.service.js.map +1 -1
- package/dist/decorators/worker-task.decorator.js.map +1 -1
- package/dist/di/di-serializer.js +27 -16
- package/dist/di/di-serializer.js.map +1 -1
- package/dist/di/worker-container.d.ts +24 -5
- package/dist/di/worker-container.js +70 -30
- package/dist/di/worker-container.js.map +1 -1
- package/dist/discovery/discovery.service.js +6 -15
- package/dist/discovery/discovery.service.js.map +1 -1
- package/dist/example/bench.js +10 -2
- package/dist/example/bench.js.map +1 -1
- package/dist/example/image.service.d.ts +8 -0
- package/dist/example/image.service.js +31 -0
- package/dist/example/image.service.js.map +1 -1
- package/dist/example/main.js +26 -9
- package/dist/example/main.js.map +1 -1
- package/dist/health/worker.health.js +4 -3
- package/dist/health/worker.health.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/metrics/worker.metrics.js +6 -2
- package/dist/metrics/worker.metrics.js.map +1 -1
- package/dist/worker/worker-runtime.js +81 -72
- package/dist/worker/worker-runtime.js.map +1 -1
- package/package.json +85 -67
package/dist/core/worker.pool.js
CHANGED
|
@@ -3,16 +3,27 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.WorkerPool = void 0;
|
|
6
|
+
exports.WorkerPool = exports.QueueFullError = void 0;
|
|
7
7
|
const node_worker_threads_1 = require("node:worker_threads");
|
|
8
8
|
const node_events_1 = require("node:events");
|
|
9
9
|
const node_os_1 = __importDefault(require("node:os"));
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
11
|
+
/** Cross-version AbortError factory (DOMException only since Node 17). */
|
|
12
|
+
function makeAbortError(message) {
|
|
13
|
+
if (typeof DOMException === 'function') {
|
|
14
|
+
return new DOMException(message, 'AbortError');
|
|
15
|
+
}
|
|
16
|
+
const err = new Error(message);
|
|
17
|
+
err.name = 'AbortError';
|
|
18
|
+
return err;
|
|
19
|
+
}
|
|
20
|
+
class QueueFullError extends Error {
|
|
21
|
+
constructor(depth) {
|
|
22
|
+
super(`WorkerPool queue is full (maxQueueDepth=${depth})`);
|
|
23
|
+
this.name = 'QueueFullError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.QueueFullError = QueueFullError;
|
|
16
27
|
// Per-worker state attached via a symbol-keyed slot — avoids a Map lookup
|
|
17
28
|
// per message and per dispatch on the hot path.
|
|
18
29
|
const STATE = Symbol('nestworker:state');
|
|
@@ -32,10 +43,17 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
32
43
|
*/
|
|
33
44
|
idle = [];
|
|
34
45
|
warmingUp = new Set();
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
// Per-priority FIFO buckets. Replaces the single sorted queue: enqueue is
|
|
47
|
+
// now O(1) push (no binary search + splice), dequeue is O(1) pop from the
|
|
48
|
+
// highest non-empty bucket. Long bursts of mixed-priority jobs no longer
|
|
49
|
+
// pay an O(n) shift cost.
|
|
50
|
+
queueHigh = [];
|
|
51
|
+
queueNormal = [];
|
|
52
|
+
queueLow = [];
|
|
53
|
+
queueHighHead = 0;
|
|
54
|
+
queueNormalHead = 0;
|
|
55
|
+
queueLowHead = 0;
|
|
56
|
+
queuedCount = 0;
|
|
39
57
|
destroyed = false;
|
|
40
58
|
activeCount = 0;
|
|
41
59
|
/**
|
|
@@ -47,16 +65,17 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
47
65
|
* coalesced into a single postMessage per worker.
|
|
48
66
|
*/
|
|
49
67
|
scheduleQueued = false;
|
|
50
|
-
/** Maps abortSignalId → worker currently running that job */
|
|
51
|
-
signalWorkerMap = new Map();
|
|
52
68
|
concurrency;
|
|
69
|
+
maxQueueDepth;
|
|
53
70
|
proxyMap = new Map();
|
|
54
|
-
constructor(services, proxyInstances, size = node_os_1.default.cpus().length, shutdownTimeout = 30_000, concurrency = 1) {
|
|
71
|
+
constructor(services, proxyInstances, size = node_os_1.default.cpus().length, shutdownTimeout = 30_000, concurrency = 1, maxQueueDepth = Number.POSITIVE_INFINITY) {
|
|
55
72
|
super();
|
|
56
73
|
this.services = services;
|
|
57
74
|
this.size = size;
|
|
58
75
|
this.shutdownTimeout = shutdownTimeout;
|
|
59
76
|
this.concurrency = concurrency > 0 ? concurrency : 1;
|
|
77
|
+
this.maxQueueDepth =
|
|
78
|
+
maxQueueDepth > 0 ? maxQueueDepth : Number.POSITIVE_INFINITY;
|
|
60
79
|
for (const { propertyKey, instance } of proxyInstances) {
|
|
61
80
|
this.proxyMap.set(propertyKey, instance);
|
|
62
81
|
}
|
|
@@ -70,7 +89,13 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
70
89
|
return new Promise((resolve, reject) => {
|
|
71
90
|
// Reject immediately if already aborted
|
|
72
91
|
if (signal?.aborted) {
|
|
73
|
-
reject(
|
|
92
|
+
reject(makeAbortError('Task aborted before enqueue'));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Backpressure: enforce queue depth before allocating anything.
|
|
96
|
+
const depth = this.queuedCount;
|
|
97
|
+
if (depth >= this.maxQueueDepth) {
|
|
98
|
+
reject(new QueueFullError(this.maxQueueDepth));
|
|
74
99
|
return;
|
|
75
100
|
}
|
|
76
101
|
const task = {
|
|
@@ -85,57 +110,85 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
85
110
|
signal,
|
|
86
111
|
};
|
|
87
112
|
if (signal) {
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
const handler = () => {
|
|
114
|
+
// Tombstone the task in its bucket if it's still queued. We don't
|
|
115
|
+
// know which bucket it lives in without checking, so try each;
|
|
116
|
+
// each scan walks ONLY the live region (head..length) of its
|
|
117
|
+
// bucket. In the worst case the task isn't queued at all (already
|
|
118
|
+
// dispatched), so we fall through to the running-task path.
|
|
119
|
+
const buckets = [
|
|
120
|
+
[this.queueHigh, this.queueHighHead],
|
|
121
|
+
[this.queueNormal, this.queueNormalHead],
|
|
122
|
+
[this.queueLow, this.queueLowHead],
|
|
123
|
+
];
|
|
124
|
+
for (const [bucket, head] of buckets) {
|
|
125
|
+
for (let i = head; i < bucket.length; i++) {
|
|
126
|
+
if (bucket[i] === task) {
|
|
127
|
+
bucket[i] = undefined;
|
|
128
|
+
this.queuedCount--;
|
|
129
|
+
this.detachAbort(task);
|
|
130
|
+
reject(makeAbortError('Task aborted'));
|
|
131
|
+
return;
|
|
98
132
|
}
|
|
99
|
-
reject(new DOMException('Task aborted', 'AbortError'));
|
|
100
|
-
return;
|
|
101
133
|
}
|
|
102
134
|
}
|
|
103
|
-
// Already running —
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
135
|
+
// Already running — forward abort to its worker.
|
|
136
|
+
const worker = task.worker;
|
|
137
|
+
if (worker && job.abortSignalId !== undefined) {
|
|
138
|
+
try {
|
|
139
|
+
worker.postMessage({
|
|
140
|
+
type: 'abort',
|
|
141
|
+
abortSignalId: job.abortSignalId,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* worker gone */
|
|
112
146
|
}
|
|
113
147
|
}
|
|
114
|
-
}
|
|
148
|
+
};
|
|
149
|
+
task.abortHandler = handler;
|
|
150
|
+
signal.addEventListener('abort', handler, { once: true });
|
|
115
151
|
}
|
|
116
152
|
this.enqueue(task);
|
|
117
153
|
this.schedule();
|
|
118
154
|
});
|
|
119
155
|
}
|
|
156
|
+
/** Remove the abort listener installed in `execute()` (idempotent). */
|
|
157
|
+
detachAbort(task) {
|
|
158
|
+
if (task.abortHandler && task.signal) {
|
|
159
|
+
try {
|
|
160
|
+
task.signal.removeEventListener('abort', task.abortHandler);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
task.abortHandler = undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
120
168
|
stats() {
|
|
169
|
+
const queued = this.queuedCount;
|
|
170
|
+
const cap = this.maxQueueDepth;
|
|
121
171
|
return {
|
|
122
172
|
poolSize: this.size,
|
|
123
173
|
idle: this.idle.length,
|
|
124
174
|
busy: this.activeCount,
|
|
125
|
-
queued
|
|
175
|
+
queued,
|
|
126
176
|
warmingUp: this.warmingUp.size,
|
|
177
|
+
saturation: Number.isFinite(cap) ? queued / cap : 0,
|
|
178
|
+
maxQueueDepth: cap,
|
|
127
179
|
};
|
|
128
180
|
}
|
|
129
181
|
spawnWorker() {
|
|
130
|
-
const worker = new node_worker_threads_1.Worker(node_path_1.default.resolve(__dirname, '../worker/worker-runtime.js'), {
|
|
131
|
-
|
|
182
|
+
const worker = new node_worker_threads_1.Worker(node_path_1.default.resolve(__dirname, '../worker/worker-runtime.js'), {
|
|
183
|
+
workerData: { services: this.services },
|
|
184
|
+
});
|
|
185
|
+
worker[STATE] = {
|
|
186
|
+
active: new Map(),
|
|
187
|
+
replaced: false,
|
|
188
|
+
};
|
|
132
189
|
this.workers.push(worker);
|
|
133
190
|
this.warmingUp.add(worker);
|
|
134
191
|
// ── Single persistent message listener ───────────────────────────────
|
|
135
|
-
// We used to add/remove a listener per dispatch — that allocated several
|
|
136
|
-
// closures and mutated the EventEmitter's listener array on every task.
|
|
137
|
-
// Instead we install one listener whose behaviour switches based on
|
|
138
|
-
// whether this worker is currently warming up or running a task.
|
|
139
192
|
const onMessage = (msg) => {
|
|
140
193
|
const message = msg;
|
|
141
194
|
if (this.warmingUp.has(worker)) {
|
|
@@ -143,8 +196,6 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
143
196
|
return;
|
|
144
197
|
this.warmingUp.delete(worker);
|
|
145
198
|
if (!this.destroyed) {
|
|
146
|
-
// Push the worker into the idle queue once per concurrency slot
|
|
147
|
-
// so the scheduler will pipeline up to N jobs into it.
|
|
148
199
|
for (let i = 0; i < this.concurrency; i++)
|
|
149
200
|
this.idle.push(worker);
|
|
150
201
|
this.schedule();
|
|
@@ -154,7 +205,6 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
154
205
|
// Spurious `worker:ready` after warmup → ignore.
|
|
155
206
|
if (message?.type === 'worker:ready')
|
|
156
207
|
return;
|
|
157
|
-
// IPC invoke from worker is not job-scoped — handle inline.
|
|
158
208
|
if (message?.type === 'ipc:invoke') {
|
|
159
209
|
this.handleIpcInvoke(worker, message);
|
|
160
210
|
return;
|
|
@@ -166,7 +216,9 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
166
216
|
const results = batch.results;
|
|
167
217
|
for (let i = 0; i < results.length; i++) {
|
|
168
218
|
const r = results[i];
|
|
169
|
-
|
|
219
|
+
if (r.jobId === undefined)
|
|
220
|
+
continue;
|
|
221
|
+
const slot = state.active.get(r.jobId);
|
|
170
222
|
if (!slot)
|
|
171
223
|
continue;
|
|
172
224
|
state.active.delete(r.jobId);
|
|
@@ -176,31 +228,24 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
176
228
|
this.dispatchNow();
|
|
177
229
|
return;
|
|
178
230
|
}
|
|
179
|
-
//
|
|
231
|
+
// Single job result — route by jobId.
|
|
180
232
|
const result = message;
|
|
233
|
+
if (result.jobId === undefined)
|
|
234
|
+
return;
|
|
181
235
|
const state = getState(worker);
|
|
182
|
-
const
|
|
183
|
-
let slot;
|
|
184
|
-
if (jobId !== undefined) {
|
|
185
|
-
slot = state.active.get(jobId);
|
|
186
|
-
if (slot)
|
|
187
|
-
state.active.delete(jobId);
|
|
188
|
-
}
|
|
189
|
-
else if (state.active.size === 1) {
|
|
190
|
-
// Concurrency == 1 back-compat: single in-flight job, no need for ID.
|
|
191
|
-
const it = state.active.values().next();
|
|
192
|
-
slot = it.value;
|
|
193
|
-
state.active.clear();
|
|
194
|
-
}
|
|
236
|
+
const slot = state.active.get(result.jobId);
|
|
195
237
|
if (!slot)
|
|
196
238
|
return; // late message for an aborted/timed-out task
|
|
239
|
+
state.active.delete(result.jobId);
|
|
197
240
|
this.completeJob(worker, slot, result);
|
|
198
241
|
if (!this.destroyed)
|
|
199
242
|
this.dispatchNow();
|
|
200
243
|
};
|
|
201
244
|
worker.on('message', onMessage);
|
|
202
|
-
|
|
203
|
-
|
|
245
|
+
// Use .on (not .once): a wedged worker can emit multiple errors before
|
|
246
|
+
// exit. handleWorkerError is idempotent via WorkerState.replaced.
|
|
247
|
+
worker.on('error', (err) => this.handleWorkerError(worker, err));
|
|
248
|
+
worker.on('exit', (code) => this.handleWorkerExit(worker, code));
|
|
204
249
|
return worker;
|
|
205
250
|
}
|
|
206
251
|
handleIpcInvoke(worker, req) {
|
|
@@ -212,17 +257,23 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
212
257
|
catch {
|
|
213
258
|
try {
|
|
214
259
|
worker.postMessage({
|
|
215
|
-
type: 'ipc:result',
|
|
260
|
+
type: 'ipc:result',
|
|
261
|
+
callId: res.callId,
|
|
262
|
+
ok: false,
|
|
216
263
|
error: `IPC result for "${req.propertyKey}.${req.methodName}" ` +
|
|
217
264
|
`is not structuredClone-compatible.`,
|
|
218
265
|
});
|
|
219
266
|
}
|
|
220
|
-
catch {
|
|
267
|
+
catch {
|
|
268
|
+
/* worker gone */
|
|
269
|
+
}
|
|
221
270
|
}
|
|
222
271
|
};
|
|
223
272
|
if (!svcInstance) {
|
|
224
273
|
reply({
|
|
225
|
-
type: 'ipc:result',
|
|
274
|
+
type: 'ipc:result',
|
|
275
|
+
callId: req.callId,
|
|
276
|
+
ok: false,
|
|
226
277
|
error: `No proxy registered for "${req.propertyKey}"`,
|
|
227
278
|
});
|
|
228
279
|
return;
|
|
@@ -230,99 +281,183 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
230
281
|
const method = svcInstance[req.methodName];
|
|
231
282
|
if (typeof method !== 'function') {
|
|
232
283
|
reply({
|
|
233
|
-
type: 'ipc:result',
|
|
284
|
+
type: 'ipc:result',
|
|
285
|
+
callId: req.callId,
|
|
286
|
+
ok: false,
|
|
234
287
|
error: `Method "${req.methodName}" not found on "${req.propertyKey}"`,
|
|
235
288
|
});
|
|
236
289
|
return;
|
|
237
290
|
}
|
|
291
|
+
// Forward the originating task's AbortSignal as the last arg when the
|
|
292
|
+
// worker provided one — lets proxy implementations honour cancellation
|
|
293
|
+
// (fetch, child_process, etc.) instead of running past it.
|
|
294
|
+
let callArgs = req.args;
|
|
295
|
+
if (req.abortSignalId !== undefined) {
|
|
296
|
+
const sig = this.findSignalById(req.abortSignalId);
|
|
297
|
+
if (sig)
|
|
298
|
+
callArgs = [...req.args, sig];
|
|
299
|
+
}
|
|
238
300
|
let p;
|
|
239
301
|
try {
|
|
240
|
-
p =
|
|
302
|
+
p = method.apply(svcInstance, callArgs);
|
|
241
303
|
}
|
|
242
304
|
catch (err) {
|
|
243
305
|
reply({
|
|
244
|
-
type: 'ipc:result',
|
|
306
|
+
type: 'ipc:result',
|
|
307
|
+
callId: req.callId,
|
|
308
|
+
ok: false,
|
|
245
309
|
error: err.message ?? String(err),
|
|
246
310
|
});
|
|
247
311
|
return;
|
|
248
312
|
}
|
|
249
313
|
// Sync fast path for proxies that return plain values.
|
|
250
|
-
if (p === null ||
|
|
314
|
+
if (p === null ||
|
|
315
|
+
typeof p !== 'object' ||
|
|
316
|
+
typeof p.then !== 'function') {
|
|
251
317
|
reply({ type: 'ipc:result', callId: req.callId, ok: true, data: p });
|
|
252
318
|
return;
|
|
253
319
|
}
|
|
254
320
|
Promise.resolve(p).then((data) => reply({ type: 'ipc:result', callId: req.callId, ok: true, data }), (err) => reply({
|
|
255
|
-
type: 'ipc:result',
|
|
321
|
+
type: 'ipc:result',
|
|
322
|
+
callId: req.callId,
|
|
323
|
+
ok: false,
|
|
256
324
|
error: err.message ?? String(err),
|
|
257
325
|
}));
|
|
258
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Look up the original caller's AbortSignal by abortSignalId. Used by
|
|
329
|
+
* `handleIpcInvoke` to forward cancellation into proxy methods.
|
|
330
|
+
* Scan is bounded by `concurrency * poolSize` and only happens on proxy
|
|
331
|
+
* calls from signal-bearing tasks, so we don't bother with a side index.
|
|
332
|
+
*/
|
|
333
|
+
findSignalById(abortSignalId) {
|
|
334
|
+
for (const worker of this.workers) {
|
|
335
|
+
const state = worker[STATE];
|
|
336
|
+
if (!state)
|
|
337
|
+
continue;
|
|
338
|
+
for (const slot of state.active.values()) {
|
|
339
|
+
if (slot.task.job.abortSignalId === abortSignalId)
|
|
340
|
+
return slot.task.signal;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
259
345
|
enqueue(task) {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
346
|
+
const bucket = task.priority === 'HIGH'
|
|
347
|
+
? this.queueHigh
|
|
348
|
+
: task.priority === 'LOW'
|
|
349
|
+
? this.queueLow
|
|
350
|
+
: this.queueNormal;
|
|
351
|
+
bucket.push(task);
|
|
352
|
+
this.queuedCount++;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Pop the next ready task in priority order (HIGH > NORMAL > LOW). Skips
|
|
356
|
+
* tombstoned entries (aborted-while-queued) and lazily compacts each
|
|
357
|
+
* bucket once its head pointer wastes >1024 slots.
|
|
358
|
+
*
|
|
359
|
+
* Returns `undefined` when all buckets are empty or contain only
|
|
360
|
+
* tombstones — caller must check.
|
|
361
|
+
*/
|
|
362
|
+
dequeue() {
|
|
363
|
+
// HIGH
|
|
364
|
+
while (this.queueHighHead < this.queueHigh.length) {
|
|
365
|
+
const t = this.queueHigh[this.queueHighHead];
|
|
366
|
+
this.queueHigh[this.queueHighHead] = undefined;
|
|
367
|
+
this.queueHighHead++;
|
|
368
|
+
this.maybeCompact(this.queueHigh, 'queueHighHead');
|
|
369
|
+
if (t) {
|
|
370
|
+
this.queuedCount--;
|
|
371
|
+
return t;
|
|
372
|
+
}
|
|
269
373
|
}
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
374
|
+
// NORMAL
|
|
375
|
+
while (this.queueNormalHead < this.queueNormal.length) {
|
|
376
|
+
const t = this.queueNormal[this.queueNormalHead];
|
|
377
|
+
this.queueNormal[this.queueNormalHead] = undefined;
|
|
378
|
+
this.queueNormalHead++;
|
|
379
|
+
this.maybeCompact(this.queueNormal, 'queueNormalHead');
|
|
380
|
+
if (t) {
|
|
381
|
+
this.queuedCount--;
|
|
382
|
+
return t;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// LOW
|
|
386
|
+
while (this.queueLowHead < this.queueLow.length) {
|
|
387
|
+
const t = this.queueLow[this.queueLowHead];
|
|
388
|
+
this.queueLow[this.queueLowHead] = undefined;
|
|
389
|
+
this.queueLowHead++;
|
|
390
|
+
this.maybeCompact(this.queueLow, 'queueLowHead');
|
|
391
|
+
if (t) {
|
|
392
|
+
this.queuedCount--;
|
|
393
|
+
return t;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
maybeCompact(bucket, headProp) {
|
|
399
|
+
const head = this[headProp];
|
|
400
|
+
if (head > 1024 && head * 2 > bucket.length) {
|
|
401
|
+
bucket.splice(0, head);
|
|
402
|
+
this[headProp] = 0;
|
|
278
403
|
}
|
|
279
|
-
|
|
404
|
+
}
|
|
405
|
+
/** True iff at least one bucket has a non-tombstone entry remaining. */
|
|
406
|
+
hasQueued() {
|
|
407
|
+
return this.queuedCount > 0;
|
|
280
408
|
}
|
|
281
409
|
schedule() {
|
|
282
410
|
if (this.destroyed || this.scheduleQueued)
|
|
283
411
|
return;
|
|
284
|
-
if (this.idle.length === 0 || this.
|
|
412
|
+
if (this.idle.length === 0 || !this.hasQueued())
|
|
285
413
|
return;
|
|
286
414
|
this.scheduleQueued = true;
|
|
287
415
|
queueMicrotask(this.drain);
|
|
288
416
|
}
|
|
289
417
|
/**
|
|
290
|
-
* Synchronous drain used on the COMPLETION path
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* adds a full microtask hop per round-trip, which dominates throughput
|
|
294
|
-
* for short tasks with concurrency=1.
|
|
295
|
-
*
|
|
296
|
-
* Initial-burst dispatch still goes through the deferred `schedule()` so
|
|
297
|
-
* synchronous floods of `execute()` calls get coalesced into per-worker
|
|
298
|
-
* batches.
|
|
418
|
+
* Synchronous drain used on the COMPLETION path. Initial-burst dispatch
|
|
419
|
+
* still goes through the deferred `schedule()` so synchronous floods of
|
|
420
|
+
* `execute()` calls get coalesced into per-worker batches.
|
|
299
421
|
*/
|
|
300
422
|
dispatchNow() {
|
|
301
423
|
if (this.destroyed)
|
|
302
424
|
return;
|
|
303
|
-
|
|
304
|
-
const q = this.queue;
|
|
305
|
-
// If a microtask drain is already queued (initial burst still in flight),
|
|
306
|
-
// let it own the dispatch — running both would race and double-dispatch.
|
|
425
|
+
// If a microtask drain is already queued, let it own the dispatch.
|
|
307
426
|
if (this.scheduleQueued)
|
|
308
427
|
return;
|
|
309
|
-
|
|
428
|
+
this.flushDispatch(true);
|
|
429
|
+
}
|
|
430
|
+
/** Pre-bound for queueMicrotask — avoids closure allocation per schedule. */
|
|
431
|
+
drain = () => {
|
|
432
|
+
this.scheduleQueued = false;
|
|
433
|
+
if (this.destroyed)
|
|
434
|
+
return;
|
|
435
|
+
this.flushDispatch(false);
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* Common dispatch loop shared by `dispatchNow` (synchronous, post-result)
|
|
439
|
+
* and `drain` (microtask, post-burst). Pops idle slots and ships jobs as
|
|
440
|
+
* either bare `WorkerJob` (single) or `WorkerJobBatch` envelopes.
|
|
441
|
+
*
|
|
442
|
+
* @param fastSingle When true, take a fast path for "exactly one job to
|
|
443
|
+
* exactly one idle worker" that skips the per-worker Map allocation.
|
|
444
|
+
*/
|
|
445
|
+
flushDispatch(fastSingle) {
|
|
446
|
+
const idle = this.idle;
|
|
447
|
+
if (idle.length === 0 || !this.hasQueued())
|
|
310
448
|
return;
|
|
311
449
|
let batches;
|
|
312
|
-
while (idle.length > 0
|
|
450
|
+
while (idle.length > 0) {
|
|
451
|
+
const task = this.dequeue();
|
|
452
|
+
if (!task)
|
|
453
|
+
break;
|
|
313
454
|
const worker = idle.pop();
|
|
314
|
-
const task = q[this.queueHead];
|
|
315
|
-
q[this.queueHead] = undefined;
|
|
316
|
-
this.queueHead++;
|
|
317
|
-
if (this.queueHead > 1024 && this.queueHead * 2 > q.length) {
|
|
318
|
-
q.splice(0, this.queueHead);
|
|
319
|
-
this.queueHead = 0;
|
|
320
|
-
}
|
|
321
455
|
this.prepareDispatch(worker, task);
|
|
322
|
-
// Fast path
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
456
|
+
// Fast path: ONE worker idle, ONE job dispatched — ship immediately.
|
|
457
|
+
if (fastSingle &&
|
|
458
|
+
batches === undefined &&
|
|
459
|
+
(idle.length === 0 || !this.hasQueued())) {
|
|
460
|
+
this.safePostJob(worker, task, task.job);
|
|
326
461
|
return;
|
|
327
462
|
}
|
|
328
463
|
if (batches === undefined)
|
|
@@ -338,63 +473,72 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
338
473
|
return;
|
|
339
474
|
for (const [worker, jobs] of batches) {
|
|
340
475
|
if (jobs.length === 1) {
|
|
341
|
-
worker.
|
|
476
|
+
const slot = getState(worker).active.get(jobs[0].jobId);
|
|
477
|
+
this.safePostJob(worker, slot?.task, jobs[0]);
|
|
342
478
|
}
|
|
343
479
|
else {
|
|
344
|
-
|
|
480
|
+
this.safePostBatch(worker, jobs);
|
|
345
481
|
}
|
|
346
482
|
}
|
|
347
483
|
}
|
|
348
|
-
/**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
484
|
+
/**
|
|
485
|
+
* Post a single job; on serialization failure synthesise a failure result
|
|
486
|
+
* so the caller's promise rejects instead of hanging on the active map.
|
|
487
|
+
*/
|
|
488
|
+
safePostJob(worker, task, job) {
|
|
489
|
+
try {
|
|
490
|
+
worker.postMessage(job);
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
const state = getState(worker);
|
|
494
|
+
const slot = state.active.get(job.jobId);
|
|
495
|
+
if (slot) {
|
|
496
|
+
state.active.delete(job.jobId);
|
|
497
|
+
this.completeJob(worker, slot, {
|
|
498
|
+
type: 'result',
|
|
499
|
+
ok: false,
|
|
500
|
+
jobId: job.jobId,
|
|
501
|
+
error: {
|
|
502
|
+
name: 'DataCloneError',
|
|
503
|
+
message: `Failed to postMessage job "${job.serviceName}.${job.methodName}": ` +
|
|
504
|
+
(err.message ?? String(err)),
|
|
505
|
+
},
|
|
506
|
+
});
|
|
367
507
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
batches = new Map();
|
|
371
|
-
let arr = batches.get(worker);
|
|
372
|
-
if (!arr) {
|
|
373
|
-
arr = [];
|
|
374
|
-
batches.set(worker, arr);
|
|
508
|
+
else if (task) {
|
|
509
|
+
task.reject(err);
|
|
375
510
|
}
|
|
376
|
-
arr.push(task.job);
|
|
377
511
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
512
|
+
}
|
|
513
|
+
safePostBatch(worker, jobs) {
|
|
514
|
+
try {
|
|
515
|
+
worker.postMessage({ type: 'batch', jobs });
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
// Batch-level failure: fail every job in this batch individually.
|
|
519
|
+
const state = getState(worker);
|
|
520
|
+
const message = `Failed to postMessage batch: ` +
|
|
521
|
+
(err.message ?? String(err));
|
|
522
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
523
|
+
const j = jobs[i];
|
|
524
|
+
const slot = state.active.get(j.jobId);
|
|
525
|
+
if (!slot)
|
|
526
|
+
continue;
|
|
527
|
+
state.active.delete(j.jobId);
|
|
528
|
+
this.completeJob(worker, slot, {
|
|
529
|
+
type: 'result',
|
|
530
|
+
ok: false,
|
|
531
|
+
jobId: j.jobId,
|
|
532
|
+
error: { name: 'DataCloneError', message },
|
|
533
|
+
});
|
|
386
534
|
}
|
|
387
535
|
}
|
|
388
|
-
}
|
|
536
|
+
}
|
|
389
537
|
prepareDispatch(worker, task) {
|
|
390
|
-
// Only pay the syscall cost when someone is actually listening.
|
|
391
538
|
const wantTiming = this.listenerCount('taskEnd') > 0 || this.listenerCount('taskStart') > 0;
|
|
392
539
|
const startedAt = wantTiming ? Date.now() : 0;
|
|
393
540
|
task.attempts++;
|
|
394
|
-
|
|
395
|
-
if (sigId !== undefined) {
|
|
396
|
-
this.signalWorkerMap.set(sigId, worker);
|
|
397
|
-
}
|
|
541
|
+
task.worker = worker;
|
|
398
542
|
const slot = { task, startedAt, settled: false };
|
|
399
543
|
const state = getState(worker);
|
|
400
544
|
state.active.set(task.job.jobId, slot);
|
|
@@ -412,14 +556,13 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
412
556
|
slot.settled = true;
|
|
413
557
|
if (slot.timeoutHandle)
|
|
414
558
|
clearTimeout(slot.timeoutHandle);
|
|
415
|
-
const sigId = slot.task.job.abortSignalId;
|
|
416
|
-
if (sigId !== undefined)
|
|
417
|
-
this.signalWorkerMap.delete(sigId);
|
|
418
559
|
this.activeCount--;
|
|
419
560
|
if (result.ok) {
|
|
420
561
|
if (this.listenerCount('taskEnd') > 0 && slot.startedAt > 0) {
|
|
421
562
|
this.emit('taskEnd', slot.task.job, Date.now() - slot.startedAt);
|
|
422
563
|
}
|
|
564
|
+
this.detachAbort(slot.task);
|
|
565
|
+
slot.task.worker = undefined;
|
|
423
566
|
slot.task.resolve(result.data);
|
|
424
567
|
}
|
|
425
568
|
else {
|
|
@@ -428,27 +571,40 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
428
571
|
message: 'Unknown worker error',
|
|
429
572
|
});
|
|
430
573
|
}
|
|
431
|
-
// Give the slot back to the pool. Scheduling is the *caller*'s job so
|
|
432
|
-
// batched-result paths can release N slots before re-scheduling — that
|
|
433
|
-
// way schedule() can batch N new dispatches into a single postMessage
|
|
434
|
-
// back to the worker.
|
|
435
574
|
if (!this.destroyed) {
|
|
436
575
|
this.idle.push(worker);
|
|
437
576
|
}
|
|
438
577
|
}
|
|
439
578
|
handleFailure(task, serializedError) {
|
|
440
579
|
const retry = task.retry ?? 0;
|
|
441
|
-
const retryDelay = task.retryDelay ?? 0;
|
|
442
580
|
if (this.listenerCount('taskError') > 0) {
|
|
443
581
|
this.emit('taskError', task.job, serializedError);
|
|
444
582
|
}
|
|
445
583
|
if (task.attempts < retry + 1) {
|
|
584
|
+
// Resolve retryDelay main-side: numeric value, or invoke fn(attempt).
|
|
585
|
+
let delay = 0;
|
|
586
|
+
const rd = task.retryDelay;
|
|
587
|
+
if (typeof rd === 'number')
|
|
588
|
+
delay = rd;
|
|
589
|
+
else if (typeof rd === 'function') {
|
|
590
|
+
try {
|
|
591
|
+
delay = Math.max(0, Number(rd(task.attempts)) || 0);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
delay = 0;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
task.worker = undefined;
|
|
446
598
|
const scheduleRetry = () => {
|
|
599
|
+
if (this.destroyed) {
|
|
600
|
+
task.reject(new Error('WorkerPool destroyed'));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
447
603
|
this.enqueue(task);
|
|
448
604
|
this.schedule();
|
|
449
605
|
};
|
|
450
|
-
if (
|
|
451
|
-
setTimeout(scheduleRetry,
|
|
606
|
+
if (delay > 0)
|
|
607
|
+
setTimeout(scheduleRetry, delay);
|
|
452
608
|
else
|
|
453
609
|
scheduleRetry();
|
|
454
610
|
return;
|
|
@@ -463,75 +619,77 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
463
619
|
failedAt: new Date(),
|
|
464
620
|
};
|
|
465
621
|
this.emit('dead', dlEvent);
|
|
622
|
+
this.detachAbort(task);
|
|
623
|
+
task.worker = undefined;
|
|
466
624
|
task.reject(deserializeError(serializedError));
|
|
467
625
|
}
|
|
468
626
|
async handleTimeout(worker, slot) {
|
|
469
627
|
if (slot.settled)
|
|
470
628
|
return;
|
|
471
629
|
slot.settled = true;
|
|
472
|
-
const sigId = slot.task.job.abortSignalId;
|
|
473
|
-
if (sigId !== undefined)
|
|
474
|
-
this.signalWorkerMap.delete(sigId);
|
|
475
630
|
const state = getState(worker);
|
|
476
631
|
state.active.delete(slot.task.job.jobId);
|
|
477
632
|
this.activeCount--;
|
|
633
|
+
// Terminate FIRST so the wedged worker is gone before any retry can be
|
|
634
|
+
// scheduled (which would otherwise race back into a worker we're about
|
|
635
|
+
// to destroy and either fail to dispatch or wedge again).
|
|
636
|
+
try {
|
|
637
|
+
await worker.terminate();
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
/* ignore */
|
|
641
|
+
}
|
|
642
|
+
this.replaceWorker(worker);
|
|
478
643
|
this.handleFailure(slot.task, {
|
|
479
644
|
name: 'TimeoutError',
|
|
480
645
|
message: `Task "${slot.task.job.serviceName}.${slot.task.job.methodName}" ` +
|
|
481
646
|
`timed out after ${slot.task.timeout}ms`,
|
|
482
647
|
});
|
|
483
|
-
// Timeouts terminate the worker (its event loop may be wedged) and
|
|
484
|
-
// replace it. All other in-flight jobs on this worker fail too.
|
|
485
|
-
try {
|
|
486
|
-
await worker.terminate();
|
|
487
|
-
}
|
|
488
|
-
catch { /* ignore */ }
|
|
489
|
-
this.replaceWorker(worker);
|
|
490
648
|
this.schedule();
|
|
491
649
|
}
|
|
492
650
|
handleWorkerError(worker, err) {
|
|
493
651
|
const state = worker[STATE];
|
|
494
|
-
if (state)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
state.active.clear();
|
|
652
|
+
if (!state || state.replaced)
|
|
653
|
+
return;
|
|
654
|
+
state.replaced = true;
|
|
655
|
+
for (const slot of state.active.values()) {
|
|
656
|
+
if (slot.settled)
|
|
657
|
+
continue;
|
|
658
|
+
slot.settled = true;
|
|
659
|
+
if (slot.timeoutHandle)
|
|
660
|
+
clearTimeout(slot.timeoutHandle);
|
|
661
|
+
const { serviceName, methodName } = slot.task.job;
|
|
662
|
+
const wrapped = new Error(`Worker crashed in "${serviceName}.${methodName}": ${err.message}`);
|
|
663
|
+
wrapped.stack = err.stack;
|
|
664
|
+
this.emit('error', wrapped, slot.task.job);
|
|
665
|
+
this.detachAbort(slot.task);
|
|
666
|
+
slot.task.worker = undefined;
|
|
667
|
+
slot.task.reject(wrapped);
|
|
668
|
+
this.activeCount--;
|
|
512
669
|
}
|
|
670
|
+
state.active.clear();
|
|
513
671
|
this.replaceWorker(worker);
|
|
514
672
|
}
|
|
515
673
|
handleWorkerExit(worker, code) {
|
|
516
674
|
if (this.destroyed)
|
|
517
675
|
return;
|
|
518
676
|
const state = worker[STATE];
|
|
519
|
-
if (state)
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
state.active.clear();
|
|
677
|
+
if (!state || state.replaced)
|
|
678
|
+
return;
|
|
679
|
+
state.replaced = true;
|
|
680
|
+
for (const slot of state.active.values()) {
|
|
681
|
+
if (slot.settled)
|
|
682
|
+
continue;
|
|
683
|
+
slot.settled = true;
|
|
684
|
+
if (slot.timeoutHandle)
|
|
685
|
+
clearTimeout(slot.timeoutHandle);
|
|
686
|
+
this.detachAbort(slot.task);
|
|
687
|
+
slot.task.worker = undefined;
|
|
688
|
+
slot.task.reject(new Error(`Worker exited with code ${code} while running ` +
|
|
689
|
+
`"${slot.task.job.serviceName}.${slot.task.job.methodName}"`));
|
|
690
|
+
this.activeCount--;
|
|
534
691
|
}
|
|
692
|
+
state.active.clear();
|
|
535
693
|
this.replaceWorker(worker);
|
|
536
694
|
}
|
|
537
695
|
replaceWorker(oldWorker) {
|
|
@@ -554,7 +712,10 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
554
712
|
}
|
|
555
713
|
async destroy() {
|
|
556
714
|
this.destroyed = true;
|
|
557
|
-
// Drain: wait for all active jobs to finish, up to shutdownTimeout
|
|
715
|
+
// Drain: wait for all active jobs to finish, up to shutdownTimeout.
|
|
716
|
+
// We track per-task settle Promises (created at execute-time) instead
|
|
717
|
+
// of monkey-patching task.resolve/reject — that approach silently
|
|
718
|
+
// dropped duplicate-settle calls and could deadlock the race.
|
|
558
719
|
if (this.activeCount > 0) {
|
|
559
720
|
const activeTasks = [];
|
|
560
721
|
for (const worker of this.workers) {
|
|
@@ -568,26 +729,41 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
568
729
|
Promise.allSettled(activeTasks.map((task) => new Promise((res) => {
|
|
569
730
|
const orig = task.resolve;
|
|
570
731
|
const origRej = task.reject;
|
|
732
|
+
let done = false;
|
|
571
733
|
task.resolve = (v) => {
|
|
734
|
+
if (!done) {
|
|
735
|
+
done = true;
|
|
736
|
+
res();
|
|
737
|
+
}
|
|
572
738
|
orig(v);
|
|
573
|
-
res();
|
|
574
739
|
};
|
|
575
740
|
task.reject = (e) => {
|
|
741
|
+
if (!done) {
|
|
742
|
+
done = true;
|
|
743
|
+
res();
|
|
744
|
+
}
|
|
576
745
|
origRej(e);
|
|
577
|
-
res();
|
|
578
746
|
};
|
|
579
747
|
}))),
|
|
580
748
|
new Promise((res) => setTimeout(res, this.shutdownTimeout)),
|
|
581
749
|
]);
|
|
582
750
|
}
|
|
583
|
-
// Reject anything still queued
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
751
|
+
// Reject anything still queued — walk each bucket and reject live entries.
|
|
752
|
+
const drainBucket = (bucket, head) => {
|
|
753
|
+
for (let i = head; i < bucket.length; i++) {
|
|
754
|
+
const queued = bucket[i];
|
|
755
|
+
if (queued) {
|
|
756
|
+
this.detachAbort(queued);
|
|
757
|
+
queued.reject(new Error('WorkerPool destroyed'));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
bucket.length = 0;
|
|
761
|
+
};
|
|
762
|
+
drainBucket(this.queueHigh, this.queueHighHead);
|
|
763
|
+
drainBucket(this.queueNormal, this.queueNormalHead);
|
|
764
|
+
drainBucket(this.queueLow, this.queueLowHead);
|
|
765
|
+
this.queueHighHead = this.queueNormalHead = this.queueLowHead = 0;
|
|
766
|
+
this.queuedCount = 0;
|
|
591
767
|
await Promise.allSettled(this.workers.map((w) => w.terminate()));
|
|
592
768
|
this.workers.length = 0;
|
|
593
769
|
this.idle.length = 0;
|