nestworker 2.1.1 → 2.1.4
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 +33 -0
- package/dist/core/worker.interfaces.d.ts +30 -15
- package/dist/core/worker.pool.d.ts +46 -5
- package/dist/core/worker.pool.js +410 -199
- package/dist/core/worker.pool.js.map +1 -1
- package/dist/core/worker.service.js +28 -15
- package/dist/core/worker.service.js.map +1 -1
- package/dist/di/worker-container.js +26 -1
- package/dist/di/worker-container.js.map +1 -1
- package/dist/example/bench.js +9 -8
- package/dist/example/bench.js.map +1 -1
- package/dist/example/image.service.d.ts +2 -0
- package/dist/example/image.service.js +10 -0
- package/dist/example/image.service.js.map +1 -1
- package/dist/example/main.js +2 -2
- package/dist/example/main.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/worker/worker-runtime.js +196 -84
- package/dist/worker/worker-runtime.js.map +1 -1
- package/package.json +9 -1
package/dist/core/worker.pool.js
CHANGED
|
@@ -13,24 +13,50 @@ const PRIORITY_WEIGHT = {
|
|
|
13
13
|
NORMAL: 2,
|
|
14
14
|
LOW: 1,
|
|
15
15
|
};
|
|
16
|
+
// Per-worker state attached via a symbol-keyed slot — avoids a Map lookup
|
|
17
|
+
// per message and per dispatch on the hot path.
|
|
18
|
+
const STATE = Symbol('nestworker:state');
|
|
19
|
+
function getState(worker) {
|
|
20
|
+
return worker[STATE];
|
|
21
|
+
}
|
|
16
22
|
class WorkerPool extends node_events_1.EventEmitter {
|
|
17
23
|
services;
|
|
18
24
|
size;
|
|
19
25
|
shutdownTimeout;
|
|
20
26
|
workers = [];
|
|
27
|
+
/**
|
|
28
|
+
* Available "slots" — each worker is pushed `concurrency` times when it
|
|
29
|
+
* becomes ready, then popped/pushed as jobs are dispatched/completed.
|
|
30
|
+
* This naturally supports per-worker pipelining without any per-job
|
|
31
|
+
* counter bookkeeping.
|
|
32
|
+
*/
|
|
21
33
|
idle = [];
|
|
22
34
|
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.
|
|
23
37
|
queue = [];
|
|
38
|
+
queueHead = 0;
|
|
24
39
|
destroyed = false;
|
|
25
|
-
|
|
40
|
+
activeCount = 0;
|
|
41
|
+
/**
|
|
42
|
+
* `schedule()` is invoked many times in a single synchronous burst (e.g.
|
|
43
|
+
* `for (...) ws.run(...)` floods 20k enqueues). Running the dispatch loop
|
|
44
|
+
* after every enqueue limits us to batches of size 1 per worker — the
|
|
45
|
+
* whole point of batching is then defeated. Defer to the next microtask
|
|
46
|
+
* so all synchronously-enqueued jobs land in one schedule pass and get
|
|
47
|
+
* coalesced into a single postMessage per worker.
|
|
48
|
+
*/
|
|
49
|
+
scheduleQueued = false;
|
|
26
50
|
/** Maps abortSignalId → worker currently running that job */
|
|
27
51
|
signalWorkerMap = new Map();
|
|
52
|
+
concurrency;
|
|
28
53
|
proxyMap = new Map();
|
|
29
|
-
constructor(services, proxyInstances, size = node_os_1.default.cpus().length, shutdownTimeout = 30_000) {
|
|
54
|
+
constructor(services, proxyInstances, size = node_os_1.default.cpus().length, shutdownTimeout = 30_000, concurrency = 1) {
|
|
30
55
|
super();
|
|
31
56
|
this.services = services;
|
|
32
57
|
this.size = size;
|
|
33
58
|
this.shutdownTimeout = shutdownTimeout;
|
|
59
|
+
this.concurrency = concurrency > 0 ? concurrency : 1;
|
|
34
60
|
for (const { propertyKey, instance } of proxyInstances) {
|
|
35
61
|
this.proxyMap.set(propertyKey, instance);
|
|
36
62
|
}
|
|
@@ -38,7 +64,7 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
38
64
|
this.spawnWorker();
|
|
39
65
|
}
|
|
40
66
|
}
|
|
41
|
-
execute(job, signal) {
|
|
67
|
+
execute(job, meta, signal) {
|
|
42
68
|
if (this.destroyed)
|
|
43
69
|
return Promise.reject(new Error('WorkerPool destroyed'));
|
|
44
70
|
return new Promise((resolve, reject) => {
|
|
@@ -49,6 +75,10 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
49
75
|
}
|
|
50
76
|
const task = {
|
|
51
77
|
job,
|
|
78
|
+
priority: meta.priority,
|
|
79
|
+
timeout: meta.timeout,
|
|
80
|
+
retry: meta.retry,
|
|
81
|
+
retryDelay: meta.retryDelay,
|
|
52
82
|
resolve: resolve,
|
|
53
83
|
reject,
|
|
54
84
|
attempts: 0,
|
|
@@ -56,15 +86,22 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
56
86
|
};
|
|
57
87
|
if (signal) {
|
|
58
88
|
signal.addEventListener('abort', () => {
|
|
59
|
-
// Remove from queue if not yet dispatched
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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++;
|
|
98
|
+
}
|
|
99
|
+
reject(new DOMException('Task aborted', 'AbortError'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
65
102
|
}
|
|
66
103
|
// Already running — send abort signal to worker
|
|
67
|
-
if (job.abortSignalId) {
|
|
104
|
+
if (job.abortSignalId !== undefined) {
|
|
68
105
|
const worker = this.signalWorkerMap.get(job.abortSignalId);
|
|
69
106
|
if (worker) {
|
|
70
107
|
try {
|
|
@@ -84,13 +121,14 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
84
121
|
return {
|
|
85
122
|
poolSize: this.size,
|
|
86
123
|
idle: this.idle.length,
|
|
87
|
-
busy: this.
|
|
88
|
-
queued: this.queue.length,
|
|
124
|
+
busy: this.activeCount,
|
|
125
|
+
queued: this.queue.length - this.queueHead,
|
|
89
126
|
warmingUp: this.warmingUp.size,
|
|
90
127
|
};
|
|
91
128
|
}
|
|
92
129
|
spawnWorker() {
|
|
93
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() };
|
|
94
132
|
this.workers.push(worker);
|
|
95
133
|
this.warmingUp.add(worker);
|
|
96
134
|
// ── Single persistent message listener ───────────────────────────────
|
|
@@ -105,7 +143,10 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
105
143
|
return;
|
|
106
144
|
this.warmingUp.delete(worker);
|
|
107
145
|
if (!this.destroyed) {
|
|
108
|
-
|
|
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
|
+
for (let i = 0; i < this.concurrency; i++)
|
|
149
|
+
this.idle.push(worker);
|
|
109
150
|
this.schedule();
|
|
110
151
|
}
|
|
111
152
|
return;
|
|
@@ -113,30 +154,124 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
113
154
|
// Spurious `worker:ready` after warmup → ignore.
|
|
114
155
|
if (message?.type === 'worker:ready')
|
|
115
156
|
return;
|
|
116
|
-
|
|
117
|
-
if (
|
|
157
|
+
// IPC invoke from worker is not job-scoped — handle inline.
|
|
158
|
+
if (message?.type === 'ipc:invoke') {
|
|
159
|
+
this.handleIpcInvoke(worker, message);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Batched job results — route each by jobId.
|
|
163
|
+
if (message?.type === 'results') {
|
|
164
|
+
const batch = message;
|
|
165
|
+
const state = getState(worker);
|
|
166
|
+
const results = batch.results;
|
|
167
|
+
for (let i = 0; i < results.length; i++) {
|
|
168
|
+
const r = results[i];
|
|
169
|
+
const slot = r.jobId !== undefined ? state.active.get(r.jobId) : undefined;
|
|
170
|
+
if (!slot)
|
|
171
|
+
continue;
|
|
172
|
+
state.active.delete(r.jobId);
|
|
173
|
+
this.completeJob(worker, slot, r);
|
|
174
|
+
}
|
|
175
|
+
if (!this.destroyed)
|
|
176
|
+
this.dispatchNow();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Job result — route by jobId to the right active slot.
|
|
180
|
+
const result = message;
|
|
181
|
+
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
|
+
}
|
|
195
|
+
if (!slot)
|
|
118
196
|
return; // late message for an aborted/timed-out task
|
|
119
|
-
|
|
197
|
+
this.completeJob(worker, slot, result);
|
|
198
|
+
if (!this.destroyed)
|
|
199
|
+
this.dispatchNow();
|
|
120
200
|
};
|
|
121
201
|
worker.on('message', onMessage);
|
|
122
202
|
worker.once('error', (err) => this.handleWorkerError(worker, err));
|
|
123
203
|
worker.once('exit', (code) => this.handleWorkerExit(worker, code));
|
|
124
204
|
return worker;
|
|
125
205
|
}
|
|
206
|
+
handleIpcInvoke(worker, req) {
|
|
207
|
+
const svcInstance = this.proxyMap.get(req.propertyKey);
|
|
208
|
+
const reply = (res) => {
|
|
209
|
+
try {
|
|
210
|
+
worker.postMessage(res);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
try {
|
|
214
|
+
worker.postMessage({
|
|
215
|
+
type: 'ipc:result', callId: res.callId, ok: false,
|
|
216
|
+
error: `IPC result for "${req.propertyKey}.${req.methodName}" ` +
|
|
217
|
+
`is not structuredClone-compatible.`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch { /* worker gone */ }
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
if (!svcInstance) {
|
|
224
|
+
reply({
|
|
225
|
+
type: 'ipc:result', callId: req.callId, ok: false,
|
|
226
|
+
error: `No proxy registered for "${req.propertyKey}"`,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const method = svcInstance[req.methodName];
|
|
231
|
+
if (typeof method !== 'function') {
|
|
232
|
+
reply({
|
|
233
|
+
type: 'ipc:result', callId: req.callId, ok: false,
|
|
234
|
+
error: `Method "${req.methodName}" not found on "${req.propertyKey}"`,
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
let p;
|
|
239
|
+
try {
|
|
240
|
+
p = svcInstance[req.methodName](...req.args);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
reply({
|
|
244
|
+
type: 'ipc:result', callId: req.callId, ok: false,
|
|
245
|
+
error: err.message ?? String(err),
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// Sync fast path for proxies that return plain values.
|
|
250
|
+
if (p === null || typeof p !== 'object' || typeof p.then !== 'function') {
|
|
251
|
+
reply({ type: 'ipc:result', callId: req.callId, ok: true, data: p });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
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,
|
|
256
|
+
error: err.message ?? String(err),
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
126
259
|
enqueue(task) {
|
|
127
|
-
const weight = PRIORITY_WEIGHT[task.
|
|
260
|
+
const weight = PRIORITY_WEIGHT[task.priority];
|
|
128
261
|
const q = this.queue;
|
|
262
|
+
const head = this.queueHead;
|
|
129
263
|
const n = q.length;
|
|
130
264
|
// Fast path: empty queue, or tail has >= priority → just push (O(1)).
|
|
131
265
|
// This is by far the most common case under steady-state load.
|
|
132
|
-
if (n ===
|
|
266
|
+
if (n === head || PRIORITY_WEIGHT[q[n - 1].priority] >= weight) {
|
|
133
267
|
q.push(task);
|
|
134
268
|
return;
|
|
135
269
|
}
|
|
136
|
-
|
|
270
|
+
// Binary search over the live region [head, n) for the insertion point.
|
|
271
|
+
let lo = head, hi = n;
|
|
137
272
|
while (lo < hi) {
|
|
138
273
|
const mid = (lo + hi) >>> 1;
|
|
139
|
-
if (PRIORITY_WEIGHT[q[mid].
|
|
274
|
+
if (PRIORITY_WEIGHT[q[mid].priority] < weight)
|
|
140
275
|
hi = mid;
|
|
141
276
|
else
|
|
142
277
|
lo = mid + 1;
|
|
@@ -144,203 +279,270 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
144
279
|
q.splice(lo, 0, task);
|
|
145
280
|
}
|
|
146
281
|
schedule() {
|
|
282
|
+
if (this.destroyed || this.scheduleQueued)
|
|
283
|
+
return;
|
|
284
|
+
if (this.idle.length === 0 || this.queueHead >= this.queue.length)
|
|
285
|
+
return;
|
|
286
|
+
this.scheduleQueued = true;
|
|
287
|
+
queueMicrotask(this.drain);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
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.
|
|
299
|
+
*/
|
|
300
|
+
dispatchNow() {
|
|
147
301
|
if (this.destroyed)
|
|
148
302
|
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
307
|
+
if (this.scheduleQueued)
|
|
308
|
+
return;
|
|
309
|
+
if (idle.length === 0 || this.queueHead >= q.length)
|
|
310
|
+
return;
|
|
311
|
+
let batches;
|
|
312
|
+
while (idle.length > 0 && this.queueHead < q.length) {
|
|
313
|
+
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
|
+
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);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (batches === undefined)
|
|
329
|
+
batches = new Map();
|
|
330
|
+
let arr = batches.get(worker);
|
|
331
|
+
if (arr === undefined) {
|
|
332
|
+
arr = [];
|
|
333
|
+
batches.set(worker, arr);
|
|
334
|
+
}
|
|
335
|
+
arr.push(task.job);
|
|
153
336
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
337
|
+
if (batches === undefined)
|
|
338
|
+
return;
|
|
339
|
+
for (const [worker, jobs] of batches) {
|
|
340
|
+
if (jobs.length === 1) {
|
|
341
|
+
worker.postMessage(jobs[0]);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
worker.postMessage({ type: 'batch', jobs });
|
|
345
|
+
}
|
|
163
346
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
347
|
+
}
|
|
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;
|
|
169
367
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (!
|
|
175
|
-
|
|
176
|
-
|
|
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);
|
|
177
375
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
};
|
|
186
|
-
const handleFailure = (serializedError) => {
|
|
187
|
-
const { retry = 0, retryDelay = 0 } = task.job;
|
|
188
|
-
this.emit('taskError', task.job, serializedError);
|
|
189
|
-
if (task.attempts < retry + 1) {
|
|
190
|
-
// Re-enqueue with delay
|
|
191
|
-
const delay = typeof retryDelay === 'number' ? retryDelay : 0;
|
|
192
|
-
const scheduleRetry = () => {
|
|
193
|
-
this.enqueue(task);
|
|
194
|
-
this.schedule();
|
|
195
|
-
};
|
|
196
|
-
if (delay > 0)
|
|
197
|
-
setTimeout(scheduleRetry, delay);
|
|
198
|
-
else
|
|
199
|
-
scheduleRetry();
|
|
200
|
-
return;
|
|
376
|
+
arr.push(task.job);
|
|
377
|
+
}
|
|
378
|
+
if (!batches)
|
|
379
|
+
return;
|
|
380
|
+
for (const [worker, jobs] of batches) {
|
|
381
|
+
if (jobs.length === 1) {
|
|
382
|
+
worker.postMessage(jobs[0]);
|
|
201
383
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
jobId: task.job.jobId,
|
|
205
|
-
serviceName: task.job.serviceName,
|
|
206
|
-
methodName: task.job.methodName,
|
|
207
|
-
args: task.job.args,
|
|
208
|
-
attempts: task.attempts,
|
|
209
|
-
error: serializedError,
|
|
210
|
-
failedAt: new Date(),
|
|
211
|
-
};
|
|
212
|
-
this.emit('dead', dlEvent);
|
|
213
|
-
task.reject(deserializeError(serializedError));
|
|
214
|
-
};
|
|
215
|
-
const handler = (msg) => {
|
|
216
|
-
const message = msg;
|
|
217
|
-
// ── IPC invoke from worker ────────────────────────────────────────
|
|
218
|
-
if (message.type === 'ipc:invoke') {
|
|
219
|
-
const req = message;
|
|
220
|
-
const svcInstance = this.proxyMap.get(req.propertyKey);
|
|
221
|
-
const reply = (res) => {
|
|
222
|
-
try {
|
|
223
|
-
worker.postMessage(res);
|
|
224
|
-
}
|
|
225
|
-
catch {
|
|
226
|
-
try {
|
|
227
|
-
worker.postMessage({
|
|
228
|
-
type: 'ipc:result', callId: res.callId, ok: false,
|
|
229
|
-
error: `IPC result for "${req.propertyKey}.${req.methodName}" ` +
|
|
230
|
-
`is not structuredClone-compatible.`,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
catch { /* worker gone */
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
};
|
|
237
|
-
if (!svcInstance) {
|
|
238
|
-
reply({
|
|
239
|
-
type: 'ipc:result', callId: req.callId, ok: false,
|
|
240
|
-
error: `No proxy registered for "${req.propertyKey}"`,
|
|
241
|
-
});
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
const method = svcInstance[req.methodName];
|
|
245
|
-
if (typeof method !== 'function') {
|
|
246
|
-
reply({
|
|
247
|
-
type: 'ipc:result', callId: req.callId, ok: false,
|
|
248
|
-
error: `Method "${req.methodName}" not found on "${req.propertyKey}"`,
|
|
249
|
-
});
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
// Invoke via svcInstance[methodName](...) to preserve `this` binding.
|
|
253
|
-
// Use Promise.resolve to handle both sync and async returns without
|
|
254
|
-
// forcing an async function allocation per call.
|
|
255
|
-
let p;
|
|
256
|
-
try {
|
|
257
|
-
p = svcInstance[req.methodName](...req.args);
|
|
258
|
-
}
|
|
259
|
-
catch (err) {
|
|
260
|
-
reply({
|
|
261
|
-
type: 'ipc:result', callId: req.callId, ok: false,
|
|
262
|
-
error: err.message ?? String(err),
|
|
263
|
-
});
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
Promise.resolve(p).then((data) => reply({ type: 'ipc:result', callId: req.callId, ok: true, data }), (err) => reply({
|
|
267
|
-
type: 'ipc:result', callId: req.callId, ok: false,
|
|
268
|
-
error: err.message ?? String(err),
|
|
269
|
-
}));
|
|
270
|
-
return;
|
|
384
|
+
else {
|
|
385
|
+
worker.postMessage({ type: 'batch', jobs });
|
|
271
386
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
prepareDispatch(worker, task) {
|
|
390
|
+
// Only pay the syscall cost when someone is actually listening.
|
|
391
|
+
const wantTiming = this.listenerCount('taskEnd') > 0 || this.listenerCount('taskStart') > 0;
|
|
392
|
+
const startedAt = wantTiming ? Date.now() : 0;
|
|
393
|
+
task.attempts++;
|
|
394
|
+
const sigId = task.job.abortSignalId;
|
|
395
|
+
if (sigId !== undefined) {
|
|
396
|
+
this.signalWorkerMap.set(sigId, worker);
|
|
397
|
+
}
|
|
398
|
+
const slot = { task, startedAt, settled: false };
|
|
399
|
+
const state = getState(worker);
|
|
400
|
+
state.active.set(task.job.jobId, slot);
|
|
401
|
+
this.activeCount++;
|
|
402
|
+
if (this.listenerCount('taskStart') > 0)
|
|
403
|
+
this.emit('taskStart', task.job);
|
|
404
|
+
if (task.timeout && task.timeout > 0) {
|
|
405
|
+
slot.timeoutHandle = setTimeout(() => this.handleTimeout(worker, slot), task.timeout);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** Called from the persistent message listener when a job result arrives. */
|
|
409
|
+
completeJob(worker, slot, result) {
|
|
410
|
+
if (slot.settled)
|
|
411
|
+
return;
|
|
412
|
+
slot.settled = true;
|
|
413
|
+
if (slot.timeoutHandle)
|
|
414
|
+
clearTimeout(slot.timeoutHandle);
|
|
415
|
+
const sigId = slot.task.job.abortSignalId;
|
|
416
|
+
if (sigId !== undefined)
|
|
417
|
+
this.signalWorkerMap.delete(sigId);
|
|
418
|
+
this.activeCount--;
|
|
419
|
+
if (result.ok) {
|
|
420
|
+
if (this.listenerCount('taskEnd') > 0 && slot.startedAt > 0) {
|
|
421
|
+
this.emit('taskEnd', slot.task.job, Date.now() - slot.startedAt);
|
|
422
|
+
}
|
|
423
|
+
slot.task.resolve(result.data);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
this.handleFailure(slot.task, result.error ?? {
|
|
427
|
+
name: 'Error',
|
|
428
|
+
message: 'Unknown worker error',
|
|
288
429
|
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
catch {
|
|
309
|
-
}
|
|
310
|
-
this.replaceWorker(worker);
|
|
430
|
+
}
|
|
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
|
+
if (!this.destroyed) {
|
|
436
|
+
this.idle.push(worker);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
handleFailure(task, serializedError) {
|
|
440
|
+
const retry = task.retry ?? 0;
|
|
441
|
+
const retryDelay = task.retryDelay ?? 0;
|
|
442
|
+
if (this.listenerCount('taskError') > 0) {
|
|
443
|
+
this.emit('taskError', task.job, serializedError);
|
|
444
|
+
}
|
|
445
|
+
if (task.attempts < retry + 1) {
|
|
446
|
+
const scheduleRetry = () => {
|
|
447
|
+
this.enqueue(task);
|
|
311
448
|
this.schedule();
|
|
312
|
-
}
|
|
449
|
+
};
|
|
450
|
+
if (retryDelay > 0)
|
|
451
|
+
setTimeout(scheduleRetry, retryDelay);
|
|
452
|
+
else
|
|
453
|
+
scheduleRetry();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const dlEvent = {
|
|
457
|
+
jobId: task.job.jobId,
|
|
458
|
+
serviceName: task.job.serviceName,
|
|
459
|
+
methodName: task.job.methodName,
|
|
460
|
+
args: task.job.args,
|
|
461
|
+
attempts: task.attempts,
|
|
462
|
+
error: serializedError,
|
|
463
|
+
failedAt: new Date(),
|
|
464
|
+
};
|
|
465
|
+
this.emit('dead', dlEvent);
|
|
466
|
+
task.reject(deserializeError(serializedError));
|
|
467
|
+
}
|
|
468
|
+
async handleTimeout(worker, slot) {
|
|
469
|
+
if (slot.settled)
|
|
470
|
+
return;
|
|
471
|
+
slot.settled = true;
|
|
472
|
+
const sigId = slot.task.job.abortSignalId;
|
|
473
|
+
if (sigId !== undefined)
|
|
474
|
+
this.signalWorkerMap.delete(sigId);
|
|
475
|
+
const state = getState(worker);
|
|
476
|
+
state.active.delete(slot.task.job.jobId);
|
|
477
|
+
this.activeCount--;
|
|
478
|
+
this.handleFailure(slot.task, {
|
|
479
|
+
name: 'TimeoutError',
|
|
480
|
+
message: `Task "${slot.task.job.serviceName}.${slot.task.job.methodName}" ` +
|
|
481
|
+
`timed out after ${slot.task.timeout}ms`,
|
|
482
|
+
});
|
|
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();
|
|
313
487
|
}
|
|
314
|
-
|
|
488
|
+
catch { /* ignore */ }
|
|
489
|
+
this.replaceWorker(worker);
|
|
490
|
+
this.schedule();
|
|
315
491
|
}
|
|
316
492
|
handleWorkerError(worker, err) {
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
493
|
+
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();
|
|
325
512
|
}
|
|
326
513
|
this.replaceWorker(worker);
|
|
327
514
|
}
|
|
328
515
|
handleWorkerExit(worker, code) {
|
|
329
516
|
if (this.destroyed)
|
|
330
517
|
return;
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
518
|
+
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();
|
|
336
534
|
}
|
|
337
535
|
this.replaceWorker(worker);
|
|
338
536
|
}
|
|
339
537
|
replaceWorker(oldWorker) {
|
|
340
538
|
const remove = (arr) => {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
539
|
+
// Remove ALL occurrences (idle holds up to `concurrency` slots per worker).
|
|
540
|
+
let w = 0;
|
|
541
|
+
for (let r = 0; r < arr.length; r++) {
|
|
542
|
+
if (arr[r] !== oldWorker)
|
|
543
|
+
arr[w++] = arr[r];
|
|
544
|
+
}
|
|
545
|
+
arr.length = w;
|
|
344
546
|
};
|
|
345
547
|
remove(this.workers);
|
|
346
548
|
remove(this.idle);
|
|
@@ -353,10 +555,17 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
353
555
|
async destroy() {
|
|
354
556
|
this.destroyed = true;
|
|
355
557
|
// Drain: wait for all active jobs to finish, up to shutdownTimeout
|
|
356
|
-
if (this.
|
|
558
|
+
if (this.activeCount > 0) {
|
|
559
|
+
const activeTasks = [];
|
|
560
|
+
for (const worker of this.workers) {
|
|
561
|
+
const state = worker[STATE];
|
|
562
|
+
if (!state)
|
|
563
|
+
continue;
|
|
564
|
+
for (const slot of state.active.values())
|
|
565
|
+
activeTasks.push(slot.task);
|
|
566
|
+
}
|
|
357
567
|
await Promise.race([
|
|
358
|
-
|
|
359
|
-
Promise.allSettled(Array.from(this.active.values()).map(({ task }) => new Promise((res) => {
|
|
568
|
+
Promise.allSettled(activeTasks.map((task) => new Promise((res) => {
|
|
360
569
|
const orig = task.resolve;
|
|
361
570
|
const origRej = task.reject;
|
|
362
571
|
task.resolve = (v) => {
|
|
@@ -368,15 +577,17 @@ class WorkerPool extends node_events_1.EventEmitter {
|
|
|
368
577
|
res();
|
|
369
578
|
};
|
|
370
579
|
}))),
|
|
371
|
-
// Force after timeout
|
|
372
580
|
new Promise((res) => setTimeout(res, this.shutdownTimeout)),
|
|
373
581
|
]);
|
|
374
582
|
}
|
|
375
583
|
// Reject anything still queued
|
|
376
|
-
for (
|
|
377
|
-
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'));
|
|
378
588
|
}
|
|
379
589
|
this.queue.length = 0;
|
|
590
|
+
this.queueHead = 0;
|
|
380
591
|
await Promise.allSettled(this.workers.map((w) => w.terminate()));
|
|
381
592
|
this.workers.length = 0;
|
|
382
593
|
this.idle.length = 0;
|