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.
Files changed (35) hide show
  1. package/README.md +684 -611
  2. package/dist/core/worker.interfaces.d.ts +38 -3
  3. package/dist/core/worker.module.js +1 -5
  4. package/dist/core/worker.module.js.map +1 -1
  5. package/dist/core/worker.pool.d.ts +52 -15
  6. package/dist/core/worker.pool.js +399 -223
  7. package/dist/core/worker.pool.js.map +1 -1
  8. package/dist/core/worker.service.d.ts +37 -3
  9. package/dist/core/worker.service.js +93 -18
  10. package/dist/core/worker.service.js.map +1 -1
  11. package/dist/decorators/worker-task.decorator.js.map +1 -1
  12. package/dist/di/di-serializer.js +27 -16
  13. package/dist/di/di-serializer.js.map +1 -1
  14. package/dist/di/worker-container.d.ts +24 -5
  15. package/dist/di/worker-container.js +70 -30
  16. package/dist/di/worker-container.js.map +1 -1
  17. package/dist/discovery/discovery.service.js +6 -15
  18. package/dist/discovery/discovery.service.js.map +1 -1
  19. package/dist/example/bench.js +10 -2
  20. package/dist/example/bench.js.map +1 -1
  21. package/dist/example/image.service.d.ts +8 -0
  22. package/dist/example/image.service.js +31 -0
  23. package/dist/example/image.service.js.map +1 -1
  24. package/dist/example/main.js +26 -9
  25. package/dist/example/main.js.map +1 -1
  26. package/dist/health/worker.health.js +4 -3
  27. package/dist/health/worker.health.js.map +1 -1
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +3 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/worker.metrics.js +6 -2
  32. package/dist/metrics/worker.metrics.js.map +1 -1
  33. package/dist/worker/worker-runtime.js +81 -72
  34. package/dist/worker/worker-runtime.js.map +1 -1
  35. package/package.json +85 -67
@@ -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
- const PRIORITY_WEIGHT = {
12
- HIGH: 3,
13
- NORMAL: 2,
14
- LOW: 1,
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
- // Head-index FIFO: queue.shift() is O(n); a head pointer makes pop O(1)
36
- // and the array is compacted lazily when it grows wasteful.
37
- queue = [];
38
- queueHead = 0;
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(new DOMException('Task aborted before enqueue', 'AbortError'));
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
- signal.addEventListener('abort', () => {
89
- // Remove from queue if not yet dispatched. Use head-index aware
90
- // search and tombstone with `undefined` to avoid an O(n) splice.
91
- const q = this.queue;
92
- for (let i = this.queueHead; i < q.length; i++) {
93
- if (q[i] === task) {
94
- q[i] = undefined;
95
- // If it's at the head, advance past it.
96
- while (this.queueHead < q.length && q[this.queueHead] === undefined) {
97
- this.queueHead++;
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 — send abort signal to worker
104
- if (job.abortSignalId !== undefined) {
105
- const worker = this.signalWorkerMap.get(job.abortSignalId);
106
- if (worker) {
107
- try {
108
- worker.postMessage({ type: 'abort', abortSignalId: job.abortSignalId });
109
- }
110
- catch { /* worker gone */
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
- }, { once: true });
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: this.queue.length - this.queueHead,
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'), { workerData: { services: this.services } });
131
- worker[STATE] = { active: new Map() };
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
- const slot = r.jobId !== undefined ? state.active.get(r.jobId) : undefined;
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
- // Job result — route by jobId to the right active slot.
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 jobId = result.jobId;
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
- worker.once('error', (err) => this.handleWorkerError(worker, err));
203
- worker.once('exit', (code) => this.handleWorkerExit(worker, code));
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', callId: res.callId, ok: false,
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 { /* worker gone */ }
267
+ catch {
268
+ /* worker gone */
269
+ }
221
270
  }
222
271
  };
223
272
  if (!svcInstance) {
224
273
  reply({
225
- type: 'ipc:result', callId: req.callId, ok: false,
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', callId: req.callId, ok: false,
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 = svcInstance[req.methodName](...req.args);
302
+ p = method.apply(svcInstance, callArgs);
241
303
  }
242
304
  catch (err) {
243
305
  reply({
244
- type: 'ipc:result', callId: req.callId, ok: false,
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 || typeof p !== 'object' || typeof p.then !== 'function') {
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', callId: req.callId, ok: false,
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 weight = PRIORITY_WEIGHT[task.priority];
261
- const q = this.queue;
262
- const head = this.queueHead;
263
- const n = q.length;
264
- // Fast path: empty queue, or tail has >= priority → just push (O(1)).
265
- // This is by far the most common case under steady-state load.
266
- if (n === head || PRIORITY_WEIGHT[q[n - 1].priority] >= weight) {
267
- q.push(task);
268
- return;
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
- // Binary search over the live region [head, n) for the insertion point.
271
- let lo = head, hi = n;
272
- while (lo < hi) {
273
- const mid = (lo + hi) >>> 1;
274
- if (PRIORITY_WEIGHT[q[mid].priority] < weight)
275
- hi = mid;
276
- else
277
- lo = mid + 1;
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
- q.splice(lo, 0, task);
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.queueHead >= this.queue.length)
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 when a worker becomes
291
- * idle as a result of a result message arriving, we want to hand it the
292
- * next queued job in the SAME tick. The microtask-deferred `schedule()`
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
- const idle = this.idle;
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
- if (idle.length === 0 || this.queueHead >= q.length)
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 && this.queueHead < q.length) {
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 for the overwhelmingly common case: ONE worker idle,
323
- // ONE job to dispatch. Skip the Map allocation and ship directly.
324
- if (batches === undefined && (idle.length === 0 || this.queueHead >= q.length)) {
325
- worker.postMessage(task.job);
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.postMessage(jobs[0]);
476
+ const slot = getState(worker).active.get(jobs[0].jobId);
477
+ this.safePostJob(worker, slot?.task, jobs[0]);
342
478
  }
343
479
  else {
344
- worker.postMessage({ type: 'batch', jobs });
480
+ this.safePostBatch(worker, jobs);
345
481
  }
346
482
  }
347
483
  }
348
- /** Pre-bound for queueMicrotask — avoids closure allocation per schedule. */
349
- drain = () => {
350
- this.scheduleQueued = false;
351
- if (this.destroyed)
352
- return;
353
- const idle = this.idle;
354
- const q = this.queue;
355
- // Collect per-worker dispatches built during this schedule pass so we
356
- // can ship them in ONE postMessage envelope per worker. Each postMessage
357
- // pays a fixed structuredClone setup cost — batching amortises it.
358
- let batches;
359
- while (idle.length > 0 && this.queueHead < q.length) {
360
- const worker = idle.pop();
361
- const task = q[this.queueHead];
362
- q[this.queueHead] = undefined;
363
- this.queueHead++;
364
- if (this.queueHead > 1024 && this.queueHead * 2 > q.length) {
365
- q.splice(0, this.queueHead);
366
- this.queueHead = 0;
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
- this.prepareDispatch(worker, task);
369
- if (!batches)
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
- if (!batches)
379
- return;
380
- for (const [worker, jobs] of batches) {
381
- if (jobs.length === 1) {
382
- worker.postMessage(jobs[0]);
383
- }
384
- else {
385
- worker.postMessage({ type: 'batch', jobs });
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
- const sigId = task.job.abortSignalId;
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 (retryDelay > 0)
451
- setTimeout(scheduleRetry, retryDelay);
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
- for (const slot of state.active.values()) {
496
- if (slot.settled)
497
- continue;
498
- slot.settled = true;
499
- if (slot.timeoutHandle)
500
- clearTimeout(slot.timeoutHandle);
501
- const sigId = slot.task.job.abortSignalId;
502
- if (sigId !== undefined)
503
- this.signalWorkerMap.delete(sigId);
504
- const { serviceName, methodName } = slot.task.job;
505
- const wrapped = new Error(`Worker crashed in "${serviceName}.${methodName}": ${err.message}`);
506
- wrapped.stack = err.stack;
507
- this.emit('error', wrapped, slot.task.job);
508
- slot.task.reject(wrapped);
509
- this.activeCount--;
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
- for (const slot of state.active.values()) {
521
- if (slot.settled)
522
- continue;
523
- slot.settled = true;
524
- if (slot.timeoutHandle)
525
- clearTimeout(slot.timeoutHandle);
526
- const sigId = slot.task.job.abortSignalId;
527
- if (sigId !== undefined)
528
- this.signalWorkerMap.delete(sigId);
529
- slot.task.reject(new Error(`Worker exited with code ${code} while running ` +
530
- `"${slot.task.job.serviceName}.${slot.task.job.methodName}"`));
531
- this.activeCount--;
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
- for (let i = this.queueHead; i < this.queue.length; i++) {
585
- const queued = this.queue[i];
586
- if (queued)
587
- queued.reject(new Error('WorkerPool destroyed'));
588
- }
589
- this.queue.length = 0;
590
- this.queueHead = 0;
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;