nestworker 2.1.6 → 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 -648
- package/dist/core/worker.interfaces.d.ts +38 -3
- package/dist/core/worker.module.js.map +1 -1
- package/dist/core/worker.pool.d.ts +52 -15
- package/dist/core/worker.pool.js +373 -216
- package/dist/core/worker.pool.js.map +1 -1
- package/dist/core/worker.service.d.ts +37 -3
- package/dist/core/worker.service.js +88 -16
- package/dist/core/worker.service.js.map +1 -1
- package/dist/di/di-serializer.js +24 -15
- 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 +8 -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 +10 -2
- package/dist/example/main.js.map +1 -1
- package/dist/health/worker.health.js +3 -1
- 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 -1
- package/dist/metrics/worker.metrics.js.map +1 -1
- package/dist/worker/worker-runtime.js +81 -73
- package/dist/worker/worker-runtime.js.map +1 -1
- package/package.json +85 -82
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,60 +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
|
-
|
|
112
|
-
|
|
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 */
|
|
113
146
|
}
|
|
114
147
|
}
|
|
115
|
-
}
|
|
148
|
+
};
|
|
149
|
+
task.abortHandler = handler;
|
|
150
|
+
signal.addEventListener('abort', handler, { once: true });
|
|
116
151
|
}
|
|
117
152
|
this.enqueue(task);
|
|
118
153
|
this.schedule();
|
|
119
154
|
});
|
|
120
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
|
+
}
|
|
121
168
|
stats() {
|
|
169
|
+
const queued = this.queuedCount;
|
|
170
|
+
const cap = this.maxQueueDepth;
|
|
122
171
|
return {
|
|
123
172
|
poolSize: this.size,
|
|
124
173
|
idle: this.idle.length,
|
|
125
174
|
busy: this.activeCount,
|
|
126
|
-
queued
|
|
175
|
+
queued,
|
|
127
176
|
warmingUp: this.warmingUp.size,
|
|
177
|
+
saturation: Number.isFinite(cap) ? queued / cap : 0,
|
|
178
|
+
maxQueueDepth: cap,
|
|
128
179
|
};
|
|
129
180
|
}
|
|
130
181
|
spawnWorker() {
|
|
131
182
|
const worker = new node_worker_threads_1.Worker(node_path_1.default.resolve(__dirname, '../worker/worker-runtime.js'), {
|
|
132
183
|
workerData: { services: this.services },
|
|
133
184
|
});
|
|
134
|
-
worker[STATE] = {
|
|
185
|
+
worker[STATE] = {
|
|
186
|
+
active: new Map(),
|
|
187
|
+
replaced: false,
|
|
188
|
+
};
|
|
135
189
|
this.workers.push(worker);
|
|
136
190
|
this.warmingUp.add(worker);
|
|
137
191
|
// ── Single persistent message listener ───────────────────────────────
|
|
138
|
-
// We used to add/remove a listener per dispatch — that allocated several
|
|
139
|
-
// closures and mutated the EventEmitter's listener array on every task.
|
|
140
|
-
// Instead we install one listener whose behaviour switches based on
|
|
141
|
-
// whether this worker is currently warming up or running a task.
|
|
142
192
|
const onMessage = (msg) => {
|
|
143
193
|
const message = msg;
|
|
144
194
|
if (this.warmingUp.has(worker)) {
|
|
@@ -146,8 +196,6 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
146
196
|
return;
|
|
147
197
|
this.warmingUp.delete(worker);
|
|
148
198
|
if (!this.destroyed) {
|
|
149
|
-
// Push the worker into the idle queue once per concurrency slot
|
|
150
|
-
// so the scheduler will pipeline up to N jobs into it.
|
|
151
199
|
for (let i = 0; i < this.concurrency; i++)
|
|
152
200
|
this.idle.push(worker);
|
|
153
201
|
this.schedule();
|
|
@@ -157,7 +205,6 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
157
205
|
// Spurious `worker:ready` after warmup → ignore.
|
|
158
206
|
if (message?.type === 'worker:ready')
|
|
159
207
|
return;
|
|
160
|
-
// IPC invoke from worker is not job-scoped — handle inline.
|
|
161
208
|
if (message?.type === 'ipc:invoke') {
|
|
162
209
|
this.handleIpcInvoke(worker, message);
|
|
163
210
|
return;
|
|
@@ -169,7 +216,9 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
169
216
|
const results = batch.results;
|
|
170
217
|
for (let i = 0; i < results.length; i++) {
|
|
171
218
|
const r = results[i];
|
|
172
|
-
|
|
219
|
+
if (r.jobId === undefined)
|
|
220
|
+
continue;
|
|
221
|
+
const slot = state.active.get(r.jobId);
|
|
173
222
|
if (!slot)
|
|
174
223
|
continue;
|
|
175
224
|
state.active.delete(r.jobId);
|
|
@@ -179,31 +228,24 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
179
228
|
this.dispatchNow();
|
|
180
229
|
return;
|
|
181
230
|
}
|
|
182
|
-
//
|
|
231
|
+
// Single job result — route by jobId.
|
|
183
232
|
const result = message;
|
|
233
|
+
if (result.jobId === undefined)
|
|
234
|
+
return;
|
|
184
235
|
const state = getState(worker);
|
|
185
|
-
const
|
|
186
|
-
let slot;
|
|
187
|
-
if (jobId !== undefined) {
|
|
188
|
-
slot = state.active.get(jobId);
|
|
189
|
-
if (slot)
|
|
190
|
-
state.active.delete(jobId);
|
|
191
|
-
}
|
|
192
|
-
else if (state.active.size === 1) {
|
|
193
|
-
// Concurrency == 1 back-compat: single in-flight job, no need for ID.
|
|
194
|
-
const it = state.active.values().next();
|
|
195
|
-
slot = it.value;
|
|
196
|
-
state.active.clear();
|
|
197
|
-
}
|
|
236
|
+
const slot = state.active.get(result.jobId);
|
|
198
237
|
if (!slot)
|
|
199
238
|
return; // late message for an aborted/timed-out task
|
|
239
|
+
state.active.delete(result.jobId);
|
|
200
240
|
this.completeJob(worker, slot, result);
|
|
201
241
|
if (!this.destroyed)
|
|
202
242
|
this.dispatchNow();
|
|
203
243
|
};
|
|
204
244
|
worker.on('message', onMessage);
|
|
205
|
-
|
|
206
|
-
|
|
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));
|
|
207
249
|
return worker;
|
|
208
250
|
}
|
|
209
251
|
handleIpcInvoke(worker, req) {
|
|
@@ -246,9 +288,18 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
246
288
|
});
|
|
247
289
|
return;
|
|
248
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
|
+
}
|
|
249
300
|
let p;
|
|
250
301
|
try {
|
|
251
|
-
p =
|
|
302
|
+
p = method.apply(svcInstance, callArgs);
|
|
252
303
|
}
|
|
253
304
|
catch (err) {
|
|
254
305
|
reply({
|
|
@@ -273,73 +324,140 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
273
324
|
error: err.message ?? String(err),
|
|
274
325
|
}));
|
|
275
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
|
+
}
|
|
276
345
|
enqueue(task) {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
+
}
|
|
286
373
|
}
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
}
|
|
295
395
|
}
|
|
296
|
-
|
|
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;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/** True iff at least one bucket has a non-tombstone entry remaining. */
|
|
406
|
+
hasQueued() {
|
|
407
|
+
return this.queuedCount > 0;
|
|
297
408
|
}
|
|
298
409
|
schedule() {
|
|
299
410
|
if (this.destroyed || this.scheduleQueued)
|
|
300
411
|
return;
|
|
301
|
-
if (this.idle.length === 0 || this.
|
|
412
|
+
if (this.idle.length === 0 || !this.hasQueued())
|
|
302
413
|
return;
|
|
303
414
|
this.scheduleQueued = true;
|
|
304
415
|
queueMicrotask(this.drain);
|
|
305
416
|
}
|
|
306
417
|
/**
|
|
307
|
-
* Synchronous drain used on the COMPLETION path
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* adds a full microtask hop per round-trip, which dominates throughput
|
|
311
|
-
* for short tasks with concurrency=1.
|
|
312
|
-
*
|
|
313
|
-
* Initial-burst dispatch still goes through the deferred `schedule()` so
|
|
314
|
-
* synchronous floods of `execute()` calls get coalesced into per-worker
|
|
315
|
-
* 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.
|
|
316
421
|
*/
|
|
317
422
|
dispatchNow() {
|
|
318
423
|
if (this.destroyed)
|
|
319
424
|
return;
|
|
320
|
-
|
|
321
|
-
const q = this.queue;
|
|
322
|
-
// If a microtask drain is already queued (initial burst still in flight),
|
|
323
|
-
// 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.
|
|
324
426
|
if (this.scheduleQueued)
|
|
325
427
|
return;
|
|
326
|
-
|
|
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())
|
|
327
448
|
return;
|
|
328
449
|
let batches;
|
|
329
|
-
while (idle.length > 0
|
|
450
|
+
while (idle.length > 0) {
|
|
451
|
+
const task = this.dequeue();
|
|
452
|
+
if (!task)
|
|
453
|
+
break;
|
|
330
454
|
const worker = idle.pop();
|
|
331
|
-
const task = q[this.queueHead];
|
|
332
|
-
q[this.queueHead] = undefined;
|
|
333
|
-
this.queueHead++;
|
|
334
|
-
if (this.queueHead > 1024 && this.queueHead * 2 > q.length) {
|
|
335
|
-
q.splice(0, this.queueHead);
|
|
336
|
-
this.queueHead = 0;
|
|
337
|
-
}
|
|
338
455
|
this.prepareDispatch(worker, task);
|
|
339
|
-
// Fast path
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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);
|
|
343
461
|
return;
|
|
344
462
|
}
|
|
345
463
|
if (batches === undefined)
|
|
@@ -355,63 +473,72 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
355
473
|
return;
|
|
356
474
|
for (const [worker, jobs] of batches) {
|
|
357
475
|
if (jobs.length === 1) {
|
|
358
|
-
worker.
|
|
476
|
+
const slot = getState(worker).active.get(jobs[0].jobId);
|
|
477
|
+
this.safePostJob(worker, slot?.task, jobs[0]);
|
|
359
478
|
}
|
|
360
479
|
else {
|
|
361
|
-
|
|
480
|
+
this.safePostBatch(worker, jobs);
|
|
362
481
|
}
|
|
363
482
|
}
|
|
364
483
|
}
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
});
|
|
384
507
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
batches = new Map();
|
|
388
|
-
let arr = batches.get(worker);
|
|
389
|
-
if (!arr) {
|
|
390
|
-
arr = [];
|
|
391
|
-
batches.set(worker, arr);
|
|
508
|
+
else if (task) {
|
|
509
|
+
task.reject(err);
|
|
392
510
|
}
|
|
393
|
-
arr.push(task.job);
|
|
394
511
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
});
|
|
403
534
|
}
|
|
404
535
|
}
|
|
405
|
-
}
|
|
536
|
+
}
|
|
406
537
|
prepareDispatch(worker, task) {
|
|
407
|
-
// Only pay the syscall cost when someone is actually listening.
|
|
408
538
|
const wantTiming = this.listenerCount('taskEnd') > 0 || this.listenerCount('taskStart') > 0;
|
|
409
539
|
const startedAt = wantTiming ? Date.now() : 0;
|
|
410
540
|
task.attempts++;
|
|
411
|
-
|
|
412
|
-
if (sigId !== undefined) {
|
|
413
|
-
this.signalWorkerMap.set(sigId, worker);
|
|
414
|
-
}
|
|
541
|
+
task.worker = worker;
|
|
415
542
|
const slot = { task, startedAt, settled: false };
|
|
416
543
|
const state = getState(worker);
|
|
417
544
|
state.active.set(task.job.jobId, slot);
|
|
@@ -429,14 +556,13 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
429
556
|
slot.settled = true;
|
|
430
557
|
if (slot.timeoutHandle)
|
|
431
558
|
clearTimeout(slot.timeoutHandle);
|
|
432
|
-
const sigId = slot.task.job.abortSignalId;
|
|
433
|
-
if (sigId !== undefined)
|
|
434
|
-
this.signalWorkerMap.delete(sigId);
|
|
435
559
|
this.activeCount--;
|
|
436
560
|
if (result.ok) {
|
|
437
561
|
if (this.listenerCount('taskEnd') > 0 && slot.startedAt > 0) {
|
|
438
562
|
this.emit('taskEnd', slot.task.job, Date.now() - slot.startedAt);
|
|
439
563
|
}
|
|
564
|
+
this.detachAbort(slot.task);
|
|
565
|
+
slot.task.worker = undefined;
|
|
440
566
|
slot.task.resolve(result.data);
|
|
441
567
|
}
|
|
442
568
|
else {
|
|
@@ -445,27 +571,40 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
445
571
|
message: 'Unknown worker error',
|
|
446
572
|
});
|
|
447
573
|
}
|
|
448
|
-
// Give the slot back to the pool. Scheduling is the *caller*'s job so
|
|
449
|
-
// batched-result paths can release N slots before re-scheduling — that
|
|
450
|
-
// way schedule() can batch N new dispatches into a single postMessage
|
|
451
|
-
// back to the worker.
|
|
452
574
|
if (!this.destroyed) {
|
|
453
575
|
this.idle.push(worker);
|
|
454
576
|
}
|
|
455
577
|
}
|
|
456
578
|
handleFailure(task, serializedError) {
|
|
457
579
|
const retry = task.retry ?? 0;
|
|
458
|
-
const retryDelay = task.retryDelay ?? 0;
|
|
459
580
|
if (this.listenerCount('taskError') > 0) {
|
|
460
581
|
this.emit('taskError', task.job, serializedError);
|
|
461
582
|
}
|
|
462
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;
|
|
463
598
|
const scheduleRetry = () => {
|
|
599
|
+
if (this.destroyed) {
|
|
600
|
+
task.reject(new Error('WorkerPool destroyed'));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
464
603
|
this.enqueue(task);
|
|
465
604
|
this.schedule();
|
|
466
605
|
};
|
|
467
|
-
if (
|
|
468
|
-
setTimeout(scheduleRetry,
|
|
606
|
+
if (delay > 0)
|
|
607
|
+
setTimeout(scheduleRetry, delay);
|
|
469
608
|
else
|
|
470
609
|
scheduleRetry();
|
|
471
610
|
return;
|
|
@@ -480,25 +619,20 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
480
619
|
failedAt: new Date(),
|
|
481
620
|
};
|
|
482
621
|
this.emit('dead', dlEvent);
|
|
622
|
+
this.detachAbort(task);
|
|
623
|
+
task.worker = undefined;
|
|
483
624
|
task.reject(deserializeError(serializedError));
|
|
484
625
|
}
|
|
485
626
|
async handleTimeout(worker, slot) {
|
|
486
627
|
if (slot.settled)
|
|
487
628
|
return;
|
|
488
629
|
slot.settled = true;
|
|
489
|
-
const sigId = slot.task.job.abortSignalId;
|
|
490
|
-
if (sigId !== undefined)
|
|
491
|
-
this.signalWorkerMap.delete(sigId);
|
|
492
630
|
const state = getState(worker);
|
|
493
631
|
state.active.delete(slot.task.job.jobId);
|
|
494
632
|
this.activeCount--;
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
`timed out after ${slot.task.timeout}ms`,
|
|
499
|
-
});
|
|
500
|
-
// Timeouts terminate the worker (its event loop may be wedged) and
|
|
501
|
-
// replace it. All other in-flight jobs on this worker fail too.
|
|
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).
|
|
502
636
|
try {
|
|
503
637
|
await worker.terminate();
|
|
504
638
|
}
|
|
@@ -506,51 +640,56 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
506
640
|
/* ignore */
|
|
507
641
|
}
|
|
508
642
|
this.replaceWorker(worker);
|
|
643
|
+
this.handleFailure(slot.task, {
|
|
644
|
+
name: 'TimeoutError',
|
|
645
|
+
message: `Task "${slot.task.job.serviceName}.${slot.task.job.methodName}" ` +
|
|
646
|
+
`timed out after ${slot.task.timeout}ms`,
|
|
647
|
+
});
|
|
509
648
|
this.schedule();
|
|
510
649
|
}
|
|
511
650
|
handleWorkerError(worker, err) {
|
|
512
651
|
const state = worker[STATE];
|
|
513
|
-
if (state)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
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--;
|
|
531
669
|
}
|
|
670
|
+
state.active.clear();
|
|
532
671
|
this.replaceWorker(worker);
|
|
533
672
|
}
|
|
534
673
|
handleWorkerExit(worker, code) {
|
|
535
674
|
if (this.destroyed)
|
|
536
675
|
return;
|
|
537
676
|
const state = worker[STATE];
|
|
538
|
-
if (state)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
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--;
|
|
553
691
|
}
|
|
692
|
+
state.active.clear();
|
|
554
693
|
this.replaceWorker(worker);
|
|
555
694
|
}
|
|
556
695
|
replaceWorker(oldWorker) {
|
|
@@ -573,7 +712,10 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
573
712
|
}
|
|
574
713
|
async destroy() {
|
|
575
714
|
this.destroyed = true;
|
|
576
|
-
// 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.
|
|
577
719
|
if (this.activeCount > 0) {
|
|
578
720
|
const activeTasks = [];
|
|
579
721
|
for (const worker of this.workers) {
|
|
@@ -587,26 +729,41 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
587
729
|
Promise.allSettled(activeTasks.map((task) => new Promise((res) => {
|
|
588
730
|
const orig = task.resolve;
|
|
589
731
|
const origRej = task.reject;
|
|
732
|
+
let done = false;
|
|
590
733
|
task.resolve = (v) => {
|
|
734
|
+
if (!done) {
|
|
735
|
+
done = true;
|
|
736
|
+
res();
|
|
737
|
+
}
|
|
591
738
|
orig(v);
|
|
592
|
-
res();
|
|
593
739
|
};
|
|
594
740
|
task.reject = (e) => {
|
|
741
|
+
if (!done) {
|
|
742
|
+
done = true;
|
|
743
|
+
res();
|
|
744
|
+
}
|
|
595
745
|
origRej(e);
|
|
596
|
-
res();
|
|
597
746
|
};
|
|
598
747
|
}))),
|
|
599
748
|
new Promise((res) => setTimeout(res, this.shutdownTimeout)),
|
|
600
749
|
]);
|
|
601
750
|
}
|
|
602
|
-
// Reject anything still queued
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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;
|
|
610
767
|
await Promise.allSettled(this.workers.map((w) => w.terminate()));
|
|
611
768
|
this.workers.length = 0;
|
|
612
769
|
this.idle.length = 0;
|