@rosepetal/node-red-contrib-async-function 1.0.0 → 1.0.2

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.
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Child Process Pool Manager
3
+ *
4
+ * Manages a pool of child processes for executing async function code.
5
+ * Handles task queuing, process lifecycle, timeouts, and error recovery.
6
+ */
7
+
8
+ const { fork } = require('child_process');
9
+ const path = require('path');
10
+ const TimeoutManager = require('./timeout-manager');
11
+ const { SharedMemoryManager } = require('./shared-memory-manager');
12
+ const { AsyncMessageSerializer } = require('./message-serializer');
13
+
14
+ // Default configuration
15
+ const DEFAULT_CONFIG = {
16
+ numWorkers: 3, // Fixed process count
17
+ taskTimeout: 30000, // Default task timeout: 30s
18
+ maxQueueSize: 100, // Max queued messages
19
+ shmThreshold: 0, // Always use shared memory for Buffers
20
+ libs: [], // External modules to load in workers
21
+ nodeRedUserDir: null, // Node-RED user directory for module resolution
22
+ workerScript: path.join(__dirname, 'child-process-script.js')
23
+ };
24
+
25
+ // Worker states
26
+ const WorkerState = {
27
+ IDLE: 'idle',
28
+ BUSY: 'busy',
29
+ STARTING: 'starting',
30
+ TERMINATING: 'terminating'
31
+ };
32
+
33
+ class ChildProcessPool {
34
+ /**
35
+ * Create a child process pool
36
+ * @param {object} config - Configuration options
37
+ */
38
+ constructor(config = {}) {
39
+ this.config = { ...DEFAULT_CONFIG, ...config };
40
+ this.workers = []; // Array of { worker, state, taskId, idleTimer }
41
+ this.taskQueue = []; // Array of { taskId, code, msg, callback, timeout }
42
+ this.nextTaskId = 0;
43
+ this.callbacks = new Map(); // taskId -> callback
44
+ this.timeoutManager = new TimeoutManager();
45
+ this.initialized = false;
46
+ this.shuttingDown = false;
47
+
48
+ // Shared memory management
49
+ this.shmManager = new SharedMemoryManager({ threshold: this.config.shmThreshold });
50
+ this.serializer = new AsyncMessageSerializer(this.shmManager);
51
+ }
52
+
53
+ /**
54
+ * Initialize the process pool
55
+ * @returns {Promise<void>}
56
+ */
57
+ async initialize() {
58
+ if (this.initialized) {
59
+ return;
60
+ }
61
+
62
+ const promises = [];
63
+ for (let i = 0; i < this.config.numWorkers; i++) {
64
+ promises.push(this.createWorker());
65
+ }
66
+
67
+ await Promise.all(promises);
68
+ this.initialized = true;
69
+ }
70
+
71
+ /**
72
+ * Create a new child process
73
+ * @returns {Promise<object>} Worker state object
74
+ */
75
+ createWorker() {
76
+ return new Promise((resolve, reject) => {
77
+ let settled = false;
78
+
79
+ const child = fork(this.config.workerScript, [], {
80
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
81
+ serialization: 'advanced'
82
+ });
83
+
84
+ const workerState = {
85
+ worker: child,
86
+ state: WorkerState.STARTING,
87
+ taskId: null,
88
+ startTime: Date.now(),
89
+ markedForRemoval: false
90
+ };
91
+
92
+ const readyTimeout = setTimeout(() => {
93
+ if (settled) {
94
+ return;
95
+ }
96
+ settled = true;
97
+ try {
98
+ child.kill();
99
+ } catch (_err) {
100
+ // Ignore kill errors
101
+ }
102
+ reject(new Error('Worker failed to start within timeout'));
103
+ }, 5000);
104
+
105
+ const handleReady = (msg) => {
106
+ if (!msg || typeof msg !== 'object') {
107
+ return;
108
+ }
109
+
110
+ if (msg.type === 'ready') {
111
+ clearTimeout(readyTimeout);
112
+ if (settled) {
113
+ return;
114
+ }
115
+ settled = true;
116
+
117
+ if (msg.failedModules && msg.failedModules.length > 0) {
118
+ const detail = msg.failedModules
119
+ .map((failed) => `${failed.module} (${failed.var}): ${failed.error}`)
120
+ .join('; ');
121
+ const err = new Error(`Worker failed to load module(s): ${detail}`);
122
+ err.failedModules = msg.failedModules;
123
+ try {
124
+ child.kill();
125
+ } catch (_err) {
126
+ // Ignore kill errors
127
+ }
128
+ reject(err);
129
+ return;
130
+ }
131
+
132
+ workerState.state = WorkerState.IDLE;
133
+ this.workers.push(workerState);
134
+ resolve(workerState);
135
+ return;
136
+ }
137
+
138
+ this.handleWorkerMessage(workerState, msg);
139
+ };
140
+
141
+ const handleError = (err) => {
142
+ if (workerState.state === WorkerState.STARTING && !settled) {
143
+ settled = true;
144
+ clearTimeout(readyTimeout);
145
+ reject(err);
146
+ }
147
+ this.handleWorkerError(workerState, err);
148
+ };
149
+
150
+ const handleExit = (code, signal) => {
151
+ if (workerState.state === WorkerState.STARTING && !settled) {
152
+ settled = true;
153
+ clearTimeout(readyTimeout);
154
+ const reason = code !== null ? `code ${code}` : `signal ${signal}`;
155
+ reject(new Error(`Worker exited during startup (${reason})`));
156
+ }
157
+ this.handleWorkerExit(workerState, code, signal);
158
+ };
159
+
160
+ child.on('message', handleReady);
161
+ child.on('error', handleError);
162
+ child.on('exit', handleExit);
163
+
164
+ try {
165
+ child.send({
166
+ type: 'init',
167
+ libs: this.config.libs || [],
168
+ nodeRedUserDir: this.config.nodeRedUserDir,
169
+ shmThreshold: this.config.shmThreshold
170
+ });
171
+ } catch (err) {
172
+ clearTimeout(readyTimeout);
173
+ try {
174
+ child.kill();
175
+ } catch (_killErr) {
176
+ // Ignore kill errors
177
+ }
178
+ reject(err);
179
+ }
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Resize the process pool gracefully
185
+ * @param {number} newNumWorkers - New target process count
186
+ * @returns {Promise<void>}
187
+ */
188
+ async resizePool(newNumWorkers) {
189
+ if (this.shuttingDown) {
190
+ throw new Error('Cannot resize pool during shutdown');
191
+ }
192
+
193
+ if (newNumWorkers < 1) {
194
+ throw new Error('numWorkers must be at least 1');
195
+ }
196
+
197
+ const currentCount = this.workers.length;
198
+ const delta = newNumWorkers - currentCount;
199
+
200
+ if (delta === 0) {
201
+ return;
202
+ }
203
+
204
+ if (delta > 0) {
205
+ const promises = [];
206
+ for (let i = 0; i < delta; i++) {
207
+ promises.push(this.createWorker());
208
+ }
209
+ await Promise.all(promises);
210
+ } else {
211
+ const toRemoveCount = Math.abs(delta);
212
+ const idleWorkers = this.workers.filter(w => w.state === WorkerState.IDLE);
213
+
214
+ const toTerminate = idleWorkers.slice(0, toRemoveCount);
215
+ await Promise.all(toTerminate.map(w => this.terminateWorker(w)));
216
+
217
+ const remainingToRemove = toRemoveCount - toTerminate.length;
218
+ if (remainingToRemove > 0) {
219
+ const busyWorkers = this.workers.filter(w => w.state === WorkerState.BUSY);
220
+ for (let i = 0; i < remainingToRemove && i < busyWorkers.length; i++) {
221
+ busyWorkers[i].markedForRemoval = true;
222
+ }
223
+ }
224
+ }
225
+
226
+ this.config.numWorkers = newNumWorkers;
227
+ }
228
+
229
+ /**
230
+ * Get an idle worker
231
+ * @returns {object|null} Worker state object or null if none available
232
+ */
233
+ async acquireWorker() {
234
+ return this.workers.find(w => w.state === WorkerState.IDLE) || null;
235
+ }
236
+
237
+ /**
238
+ * Execute a task on a worker
239
+ * @param {string} code - User function code
240
+ * @param {object} msg - Message object
241
+ * @param {number} timeout - Timeout in milliseconds
242
+ * @returns {Promise<object>} Result
243
+ */
244
+ async executeTask(code, msg, timeout = this.config.taskTimeout) {
245
+ if (!this.initialized) {
246
+ await this.initialize();
247
+ }
248
+
249
+ if (this.shuttingDown) {
250
+ throw new Error('Worker pool is shutting down');
251
+ }
252
+
253
+ const taskId = this.nextTaskId++;
254
+
255
+ return new Promise((resolve, reject) => {
256
+ const callback = (err, payload) => {
257
+ this.shmManager.cleanupTask(taskId).catch(_cleanupErr => {});
258
+
259
+ if (err) {
260
+ reject(err);
261
+ } else {
262
+ resolve(payload);
263
+ }
264
+ };
265
+
266
+ this.callbacks.set(taskId, callback);
267
+
268
+ this.acquireWorker().then(workerState => {
269
+ if (workerState) {
270
+ this.runTask(workerState, taskId, code, msg, timeout);
271
+ } else {
272
+ if (this.taskQueue.length >= this.config.maxQueueSize) {
273
+ this.callbacks.delete(taskId);
274
+ this.shmManager.cleanupTask(taskId).catch(_cleanupErr => {});
275
+ reject(new Error('Task queue full'));
276
+ return;
277
+ }
278
+
279
+ this.taskQueue.push({ taskId, code, msg, timeout, callback });
280
+ }
281
+ }).catch(err => {
282
+ this.callbacks.delete(taskId);
283
+ this.shmManager.cleanupTask(taskId).catch(_cleanupErr => {});
284
+ reject(err);
285
+ });
286
+ });
287
+ }
288
+
289
+ /**
290
+ * Run a task on a specific worker
291
+ * @param {object} workerState - Worker state object
292
+ * @param {number} taskId - Task ID
293
+ * @param {string} code - User function code
294
+ * @param {object} msg - Message object
295
+ * @param {number} timeout - Timeout in milliseconds
296
+ */
297
+ runTask(workerState, taskId, code, msg, timeout) {
298
+ workerState.state = WorkerState.BUSY;
299
+ workerState.taskId = taskId;
300
+
301
+ this.serializer.sanitizeMessage(msg, null, taskId).then(sanitizedMsg => {
302
+ this.timeoutManager.startTimeout(taskId, timeout, () => {
303
+ this.handleTimeout(workerState, taskId);
304
+ });
305
+
306
+ try {
307
+ workerState.worker.send({
308
+ type: 'execute',
309
+ taskId,
310
+ code,
311
+ msg: sanitizedMsg
312
+ });
313
+ } catch (err) {
314
+ this.handleWorkerError(workerState, err);
315
+ }
316
+ }).catch(err => {
317
+ const callback = this.callbacks.get(taskId);
318
+ if (callback) {
319
+ this.callbacks.delete(taskId);
320
+ callback(new Error(`Message sanitization failed: ${err.message}`), null);
321
+ }
322
+ this.recycleWorker(workerState);
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Handle message from worker
328
+ * @param {object} workerState - Worker state object
329
+ * @param {object} message - Message from worker
330
+ */
331
+ handleWorkerMessage(workerState, message) {
332
+ const { type, taskId, result, error, performance } = message || {};
333
+
334
+ if (type === 'result') {
335
+ this.timeoutManager.cancelTimeout(taskId);
336
+
337
+ const callback = this.callbacks.get(taskId);
338
+ if (callback) {
339
+ this.callbacks.delete(taskId);
340
+ this.recycleWorker(workerState);
341
+
342
+ this.serializer.restoreBuffers(result).then(restoredResult => {
343
+ callback(null, { result: restoredResult, performance: performance || null });
344
+ }).catch(restoreErr => {
345
+ callback(restoreErr instanceof Error ? restoreErr : new Error(String(restoreErr)), null);
346
+ });
347
+ return;
348
+ }
349
+
350
+ this.recycleWorker(workerState);
351
+
352
+ } else if (type === 'error') {
353
+ this.timeoutManager.cancelTimeout(taskId);
354
+
355
+ const callback = this.callbacks.get(taskId);
356
+ if (callback) {
357
+ this.callbacks.delete(taskId);
358
+ const err = new Error(error && error.message ? error.message : 'Worker error');
359
+ if (error && error.stack) {
360
+ err.stack = error.stack;
361
+ }
362
+ if (error && error.name) {
363
+ err.name = error.name;
364
+ }
365
+ callback(err, null);
366
+ }
367
+
368
+ this.recycleWorker(workerState);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Handle worker error
374
+ * @param {object} workerState - Worker state object
375
+ * @param {Error} err - Error object
376
+ */
377
+ async handleWorkerError(workerState, err) {
378
+ if (workerState.state === WorkerState.TERMINATING) {
379
+ this.removeWorker(workerState);
380
+ return;
381
+ }
382
+
383
+ workerState.state = WorkerState.TERMINATING;
384
+
385
+ if (workerState.taskId !== null) {
386
+ this.timeoutManager.cancelTimeout(workerState.taskId);
387
+
388
+ this.shmManager.cleanupTask(workerState.taskId).catch(_cleanupErr => {});
389
+
390
+ const callback = this.callbacks.get(workerState.taskId);
391
+ if (callback) {
392
+ this.callbacks.delete(workerState.taskId);
393
+ callback(new Error(`Worker error: ${err.message}`), null);
394
+ }
395
+ }
396
+
397
+ this.removeWorker(workerState);
398
+
399
+ if (this.workers.length < this.config.numWorkers && !this.shuttingDown) {
400
+ try {
401
+ await this.createWorker();
402
+ } catch (_createErr) {
403
+ // Failed to create replacement
404
+ }
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Handle worker exit
410
+ * @param {object} workerState - Worker state object
411
+ * @param {number} code - Exit code
412
+ * @param {string} signal - Exit signal
413
+ */
414
+ async handleWorkerExit(workerState, code, signal) {
415
+ if (workerState.state === WorkerState.TERMINATING) {
416
+ this.removeWorker(workerState);
417
+ return;
418
+ }
419
+
420
+ const reason = signal ? `signal ${signal}` : `code ${code !== null ? code : 'unknown'}`;
421
+ await this.handleWorkerError(workerState, new Error(`Worker exited (${reason})`));
422
+ }
423
+
424
+ /**
425
+ * Handle task timeout
426
+ * @param {object} workerState - Worker state object
427
+ * @param {number} taskId - Task ID
428
+ */
429
+ async handleTimeout(workerState, taskId) {
430
+ this.shmManager.cleanupTask(taskId).catch(_cleanupErr => {});
431
+
432
+ workerState.state = WorkerState.TERMINATING;
433
+
434
+ try {
435
+ await this.terminateWorker(workerState);
436
+ } catch (_err) {
437
+ // Ignore termination errors
438
+ }
439
+
440
+ this.removeWorker(workerState);
441
+
442
+ const callback = this.callbacks.get(taskId);
443
+ if (callback) {
444
+ this.callbacks.delete(taskId);
445
+ callback(new Error('Execution timeout'), null);
446
+ }
447
+
448
+ if (this.workers.length < this.config.numWorkers && !this.shuttingDown) {
449
+ try {
450
+ await this.createWorker();
451
+ } catch (_err) {
452
+ // Failed to create replacement
453
+ }
454
+ }
455
+
456
+ this.processQueue();
457
+ }
458
+
459
+ /**
460
+ * Recycle a worker after task completion
461
+ * @param {object} workerState - Worker state object
462
+ */
463
+ recycleWorker(workerState) {
464
+ workerState.state = WorkerState.IDLE;
465
+ workerState.taskId = null;
466
+
467
+ if (workerState.markedForRemoval) {
468
+ this.terminateWorker(workerState).catch(_err => {});
469
+ this.processQueue();
470
+ return;
471
+ }
472
+
473
+ if (this.taskQueue.length > 0) {
474
+ const task = this.taskQueue.shift();
475
+ this.runTask(workerState, task.taskId, task.code, task.msg, task.timeout);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Process the task queue
481
+ */
482
+ processQueue() {
483
+ if (this.taskQueue.length === 0) {
484
+ return;
485
+ }
486
+
487
+ const workerState = this.workers.find(w => w.state === WorkerState.IDLE);
488
+ if (workerState) {
489
+ const task = this.taskQueue.shift();
490
+ this.runTask(workerState, task.taskId, task.code, task.msg, task.timeout);
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Terminate a worker
496
+ * @param {object} workerState - Worker state object
497
+ */
498
+ async terminateWorker(workerState) {
499
+ workerState.state = WorkerState.TERMINATING;
500
+ const child = workerState.worker;
501
+
502
+ await new Promise((resolve) => {
503
+ let settled = false;
504
+ const finish = () => {
505
+ if (settled) {
506
+ return;
507
+ }
508
+ settled = true;
509
+ resolve();
510
+ };
511
+
512
+ const timeout = setTimeout(() => {
513
+ if (!child.killed) {
514
+ try {
515
+ child.kill();
516
+ } catch (_err) {
517
+ // Ignore kill errors
518
+ }
519
+ }
520
+ finish();
521
+ }, 250);
522
+
523
+ child.once('exit', () => {
524
+ clearTimeout(timeout);
525
+ finish();
526
+ });
527
+
528
+ child.once('error', () => {
529
+ clearTimeout(timeout);
530
+ finish();
531
+ });
532
+
533
+ if (child.connected) {
534
+ try {
535
+ child.send({ type: 'terminate' });
536
+ } catch (_err) {
537
+ // Ignore send errors
538
+ }
539
+ } else {
540
+ try {
541
+ child.kill();
542
+ } catch (_err) {
543
+ // Ignore kill errors
544
+ }
545
+ }
546
+ });
547
+
548
+ this.removeWorker(workerState);
549
+ }
550
+
551
+ /**
552
+ * Remove a worker from the pool
553
+ * @param {object} workerState - Worker state object
554
+ */
555
+ removeWorker(workerState) {
556
+ this.workers = this.workers.filter(w => w !== workerState);
557
+ }
558
+
559
+ /**
560
+ * Shutdown the worker pool
561
+ * @returns {Promise<void>}
562
+ */
563
+ async shutdown() {
564
+ this.shuttingDown = true;
565
+
566
+ this.timeoutManager.clear();
567
+
568
+ for (const task of this.taskQueue) {
569
+ const callback = this.callbacks.get(task.taskId);
570
+ if (callback) {
571
+ this.callbacks.delete(task.taskId);
572
+ callback(new Error('Worker pool shutdown'), null);
573
+ }
574
+ }
575
+ this.taskQueue = [];
576
+
577
+ const promises = this.workers.map(ws => this.terminateWorker(ws));
578
+ await Promise.all(promises);
579
+
580
+ await this.shmManager.cleanupAll();
581
+
582
+ this.initialized = false;
583
+ }
584
+
585
+ /**
586
+ * Get pool statistics
587
+ * @returns {object} Statistics object
588
+ */
589
+ getStats() {
590
+ const idleWorkers = this.workers.filter(w => w.state === WorkerState.IDLE).length;
591
+ const busyWorkers = this.workers.filter(w => w.state === WorkerState.BUSY).length;
592
+ const markedForRemoval = this.workers.filter(w => w.markedForRemoval).length;
593
+
594
+ return {
595
+ totalWorkers: this.workers.length,
596
+ targetWorkers: this.config.numWorkers,
597
+ idleWorkers,
598
+ busyWorkers,
599
+ markedForRemoval,
600
+ queuedTasks: this.taskQueue.length,
601
+ activeTasks: this.timeoutManager.getActiveCount(),
602
+ sharedMemory: this.shmManager.getStats(),
603
+ config: this.config
604
+ };
605
+ }
606
+ }
607
+
608
+ module.exports = {
609
+ ChildProcessPool
610
+ };