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

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,533 @@
1
+ /**
2
+ * Worker Pool Manager
3
+ *
4
+ * Manages a pool of worker threads for executing async function code.
5
+ * Handles task queuing, worker lifecycle, timeouts, and error recovery.
6
+ */
7
+
8
+ const { Worker } = require('worker_threads');
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 worker 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, 'worker-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 WorkerPool {
34
+ /**
35
+ * Create a worker 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 worker pool
55
+ * @returns {Promise<void>}
56
+ */
57
+ async initialize() {
58
+ if (this.initialized) {
59
+ return;
60
+ }
61
+
62
+ // Create exactly numWorkers workers
63
+ const promises = [];
64
+ for (let i = 0; i < this.config.numWorkers; i++) {
65
+ promises.push(this.createWorker());
66
+ }
67
+
68
+ await Promise.all(promises);
69
+ this.initialized = true;
70
+ }
71
+
72
+ /**
73
+ * Create a new worker
74
+ * @returns {Promise<object>} Worker state object
75
+ */
76
+ createWorker() {
77
+ return new Promise((resolve, reject) => {
78
+ try {
79
+ const worker = new Worker(this.config.workerScript, {
80
+ workerData: {
81
+ shmThreshold: this.config.shmThreshold,
82
+ libs: this.config.libs || [],
83
+ nodeRedUserDir: this.config.nodeRedUserDir
84
+ }
85
+ });
86
+
87
+ const workerState = {
88
+ worker,
89
+ state: WorkerState.STARTING,
90
+ taskId: null,
91
+ startTime: Date.now(),
92
+ markedForRemoval: false
93
+ };
94
+
95
+ // Setup event handlers
96
+ worker.on('message', (msg) => this.handleWorkerMessage(workerState, msg));
97
+ worker.on('error', (err) => this.handleWorkerError(workerState, err));
98
+ worker.on('exit', (code) => this.handleWorkerExit(workerState, code));
99
+
100
+ // Wait for ready signal
101
+ const readyTimeout = setTimeout(() => {
102
+ reject(new Error('Worker failed to start within timeout'));
103
+ }, 5000);
104
+
105
+ worker.on('message', (msg) => {
106
+ if (msg.type === 'ready') {
107
+ clearTimeout(readyTimeout);
108
+ workerState.state = WorkerState.IDLE;
109
+ this.workers.push(workerState);
110
+ resolve(workerState);
111
+ }
112
+ });
113
+
114
+ } catch (err) {
115
+ reject(err);
116
+ }
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Resize the worker pool gracefully
122
+ * @param {number} newNumWorkers - New target worker count
123
+ * @returns {Promise<void>}
124
+ */
125
+ async resizePool(newNumWorkers) {
126
+ if (this.shuttingDown) {
127
+ throw new Error('Cannot resize pool during shutdown');
128
+ }
129
+
130
+ if (newNumWorkers < 1) {
131
+ throw new Error('numWorkers must be at least 1');
132
+ }
133
+
134
+ const currentCount = this.workers.length;
135
+ const delta = newNumWorkers - currentCount;
136
+
137
+ if (delta === 0) {
138
+ return; // No change needed
139
+ }
140
+
141
+ if (delta > 0) {
142
+ // Scale up: Add new workers
143
+ const promises = [];
144
+ for (let i = 0; i < delta; i++) {
145
+ promises.push(this.createWorker());
146
+ }
147
+ await Promise.all(promises);
148
+ } else {
149
+ // Scale down: Gracefully remove workers
150
+ const toRemoveCount = Math.abs(delta);
151
+ const idleWorkers = this.workers.filter(w => w.state === WorkerState.IDLE);
152
+
153
+ // Immediately terminate idle workers
154
+ const toTerminate = idleWorkers.slice(0, toRemoveCount);
155
+ await Promise.all(toTerminate.map(w => this.terminateWorker(w)));
156
+
157
+ // Mark remaining busy workers for removal after task completion
158
+ const remainingToRemove = toRemoveCount - toTerminate.length;
159
+ if (remainingToRemove > 0) {
160
+ const busyWorkers = this.workers.filter(w => w.state === WorkerState.BUSY);
161
+ for (let i = 0; i < remainingToRemove && i < busyWorkers.length; i++) {
162
+ busyWorkers[i].markedForRemoval = true;
163
+ }
164
+ }
165
+ }
166
+
167
+ // Update config
168
+ this.config.numWorkers = newNumWorkers;
169
+ }
170
+
171
+ /**
172
+ * Get an idle worker
173
+ * @returns {object|null} Worker state object or null if none available
174
+ */
175
+ async acquireWorker() {
176
+ // Find and return idle worker (no dynamic creation)
177
+ return this.workers.find(w => w.state === WorkerState.IDLE) || null;
178
+ }
179
+
180
+ /**
181
+ * Execute a task on a worker
182
+ * @param {string} code - User function code
183
+ * @param {object} msg - Message object
184
+ * @param {number} timeout - Timeout in milliseconds
185
+ * @returns {Promise<object>} Result
186
+ */
187
+ async executeTask(code, msg, timeout = this.config.taskTimeout) {
188
+ if (!this.initialized) {
189
+ await this.initialize();
190
+ }
191
+
192
+ if (this.shuttingDown) {
193
+ throw new Error('Worker pool is shutting down');
194
+ }
195
+
196
+ const taskId = this.nextTaskId++;
197
+
198
+ return new Promise((resolve, reject) => {
199
+ const callback = (err, payload) => {
200
+ // Cleanup shared memory on completion (success or error)
201
+ this.shmManager.cleanupTask(taskId).catch(_cleanupErr => {
202
+ // Log but don't fail - task already completed
203
+ });
204
+
205
+ if (err) {
206
+ reject(err);
207
+ } else {
208
+ resolve(payload);
209
+ }
210
+ };
211
+
212
+ this.callbacks.set(taskId, callback);
213
+
214
+ // Try to acquire a worker
215
+ this.acquireWorker().then(workerState => {
216
+ if (workerState) {
217
+ // Worker available, run task immediately
218
+ this.runTask(workerState, taskId, code, msg, timeout);
219
+ } else {
220
+ // No worker available, queue the task
221
+ if (this.taskQueue.length >= this.config.maxQueueSize) {
222
+ this.callbacks.delete(taskId);
223
+ // Cleanup shared memory on queue rejection
224
+ this.shmManager.cleanupTask(taskId).catch(_cleanupErr => {});
225
+ reject(new Error('Task queue full'));
226
+ return;
227
+ }
228
+
229
+ this.taskQueue.push({ taskId, code, msg, timeout, callback });
230
+ }
231
+ }).catch(err => {
232
+ this.callbacks.delete(taskId);
233
+ // Cleanup shared memory on error
234
+ this.shmManager.cleanupTask(taskId).catch(_cleanupErr => {});
235
+ reject(err);
236
+ });
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Run a task on a specific worker
242
+ * @param {object} workerState - Worker state object
243
+ * @param {number} taskId - Task ID
244
+ * @param {string} code - User function code
245
+ * @param {object} msg - Message object
246
+ * @param {number} timeout - Timeout in milliseconds
247
+ */
248
+ runTask(workerState, taskId, code, msg, timeout) {
249
+ // Update worker state
250
+ workerState.state = WorkerState.BUSY;
251
+ workerState.taskId = taskId;
252
+
253
+ this.serializer.sanitizeMessage(msg, null, taskId).then(sanitizedMsg => {
254
+ // Start timeout after message preparation (matches hot-mode behavior)
255
+ this.timeoutManager.startTimeout(taskId, timeout, () => {
256
+ this.handleTimeout(workerState, taskId);
257
+ });
258
+
259
+ // Send task to worker
260
+ workerState.worker.postMessage({
261
+ type: 'execute',
262
+ taskId,
263
+ code,
264
+ msg: sanitizedMsg
265
+ });
266
+ }).catch(err => {
267
+ // Fail task if message prep fails
268
+ const callback = this.callbacks.get(taskId);
269
+ if (callback) {
270
+ this.callbacks.delete(taskId);
271
+ callback(new Error(`Message sanitization failed: ${err.message}`), null);
272
+ }
273
+ this.recycleWorker(workerState);
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Handle message from worker
279
+ * @param {object} workerState - Worker state object
280
+ * @param {object} message - Message from worker
281
+ */
282
+ handleWorkerMessage(workerState, message) {
283
+ const { type, taskId, result, error, performance } = message;
284
+
285
+ if (type === 'result') {
286
+ // Task completed successfully
287
+ this.timeoutManager.cancelTimeout(taskId);
288
+
289
+ const callback = this.callbacks.get(taskId);
290
+ if (callback) {
291
+ this.callbacks.delete(taskId);
292
+ // Recycle worker immediately; result restoration happens asynchronously
293
+ this.recycleWorker(workerState);
294
+
295
+ this.serializer.restoreBuffers(result).then(restoredResult => {
296
+ callback(null, { result: restoredResult, performance: performance || null });
297
+ }).catch(restoreErr => {
298
+ callback(restoreErr instanceof Error ? restoreErr : new Error(String(restoreErr)), null);
299
+ });
300
+ return;
301
+ }
302
+
303
+ // Return worker to idle state
304
+ this.recycleWorker(workerState);
305
+
306
+ } else if (type === 'error') {
307
+ // Task failed with error
308
+ this.timeoutManager.cancelTimeout(taskId);
309
+
310
+ const callback = this.callbacks.get(taskId);
311
+ if (callback) {
312
+ this.callbacks.delete(taskId);
313
+ const err = new Error(error.message);
314
+ err.stack = error.stack;
315
+ err.name = error.name;
316
+ callback(err, null);
317
+ }
318
+
319
+ // Return worker to idle state
320
+ this.recycleWorker(workerState);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Handle worker error
326
+ * @param {object} workerState - Worker state object
327
+ * @param {Error} err - Error object
328
+ */
329
+ async handleWorkerError(workerState, err) {
330
+ // Cancel timeout if task was running
331
+ if (workerState.taskId !== null) {
332
+ this.timeoutManager.cancelTimeout(workerState.taskId);
333
+
334
+ // Cleanup shared memory for crashed task
335
+ this.shmManager.cleanupTask(workerState.taskId).catch(_cleanupErr => {
336
+ // Log but don't fail
337
+ });
338
+
339
+ const callback = this.callbacks.get(workerState.taskId);
340
+ if (callback) {
341
+ this.callbacks.delete(workerState.taskId);
342
+ callback(new Error(`Worker error: ${err.message}`), null);
343
+ }
344
+ }
345
+
346
+ // Remove worker from pool
347
+ this.removeWorker(workerState);
348
+
349
+ // Create replacement if below target
350
+ if (this.workers.length < this.config.numWorkers && !this.shuttingDown) {
351
+ try {
352
+ await this.createWorker();
353
+ } catch (createErr) {
354
+ // Failed to create replacement
355
+ }
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Handle worker exit
361
+ * @param {object} workerState - Worker state object
362
+ * @param {number} code - Exit code
363
+ */
364
+ async handleWorkerExit(workerState, code) {
365
+ if (code !== 0 && workerState.state !== WorkerState.TERMINATING) {
366
+ // Worker crashed unexpectedly
367
+ await this.handleWorkerError(workerState, new Error(`Worker exited with code ${code}`));
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Handle task timeout
373
+ * @param {object} workerState - Worker state object
374
+ * @param {number} taskId - Task ID
375
+ */
376
+ async handleTimeout(workerState, taskId) {
377
+ // Cleanup shared memory for timed out task
378
+ this.shmManager.cleanupTask(taskId).catch(_cleanupErr => {
379
+ // Log but don't fail
380
+ });
381
+
382
+ // Terminate the worker
383
+ workerState.state = WorkerState.TERMINATING;
384
+
385
+ try {
386
+ await workerState.worker.terminate();
387
+ } catch (err) {
388
+ // Ignore termination errors
389
+ }
390
+
391
+ // Remove from pool
392
+ this.removeWorker(workerState);
393
+
394
+ // Fail the task
395
+ const callback = this.callbacks.get(taskId);
396
+ if (callback) {
397
+ this.callbacks.delete(taskId);
398
+ callback(new Error('Execution timeout'), null);
399
+ }
400
+
401
+ // Create replacement worker
402
+ if (this.workers.length < this.config.numWorkers && !this.shuttingDown) {
403
+ try {
404
+ await this.createWorker();
405
+ } catch (err) {
406
+ // Failed to create replacement
407
+ }
408
+ }
409
+
410
+ // Process next queued task
411
+ this.processQueue();
412
+ }
413
+
414
+ /**
415
+ * Recycle a worker after task completion
416
+ * @param {object} workerState - Worker state object
417
+ */
418
+ recycleWorker(workerState) {
419
+ workerState.state = WorkerState.IDLE;
420
+ workerState.taskId = null;
421
+
422
+ // Check if marked for removal (for resize support)
423
+ if (workerState.markedForRemoval) {
424
+ this.terminateWorker(workerState).catch(_err => {
425
+ // Log but don't fail
426
+ });
427
+ this.processQueue();
428
+ return;
429
+ }
430
+
431
+ // Process next queued task
432
+ if (this.taskQueue.length > 0) {
433
+ const task = this.taskQueue.shift();
434
+ this.runTask(workerState, task.taskId, task.code, task.msg, task.timeout);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Process the task queue
440
+ */
441
+ processQueue() {
442
+ if (this.taskQueue.length === 0) {
443
+ return;
444
+ }
445
+
446
+ // Find idle worker
447
+ const workerState = this.workers.find(w => w.state === WorkerState.IDLE);
448
+ if (workerState) {
449
+ const task = this.taskQueue.shift();
450
+ this.runTask(workerState, task.taskId, task.code, task.msg, task.timeout);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Terminate a worker
456
+ * @param {object} workerState - Worker state object
457
+ */
458
+ async terminateWorker(workerState) {
459
+ workerState.state = WorkerState.TERMINATING;
460
+
461
+ try {
462
+ await workerState.worker.terminate();
463
+ } catch (err) {
464
+ // Ignore termination errors
465
+ }
466
+
467
+ this.removeWorker(workerState);
468
+ }
469
+
470
+ /**
471
+ * Remove a worker from the pool
472
+ * @param {object} workerState - Worker state object
473
+ */
474
+ removeWorker(workerState) {
475
+ this.workers = this.workers.filter(w => w !== workerState);
476
+ }
477
+
478
+ /**
479
+ * Shutdown the worker pool
480
+ * @returns {Promise<void>}
481
+ */
482
+ async shutdown() {
483
+ this.shuttingDown = true;
484
+
485
+ // Cancel all timeouts
486
+ this.timeoutManager.clear();
487
+
488
+ // Fail all queued tasks
489
+ for (const task of this.taskQueue) {
490
+ const callback = this.callbacks.get(task.taskId);
491
+ if (callback) {
492
+ this.callbacks.delete(task.taskId);
493
+ callback(new Error('Worker pool shutdown'), null);
494
+ }
495
+ }
496
+ this.taskQueue = [];
497
+
498
+ // Terminate all workers
499
+ const promises = this.workers.map(ws => this.terminateWorker(ws));
500
+ await Promise.all(promises);
501
+
502
+ // Cleanup all shared memory attachments
503
+ await this.shmManager.cleanupAll();
504
+
505
+ this.initialized = false;
506
+ }
507
+
508
+ /**
509
+ * Get pool statistics
510
+ * @returns {object} Statistics object
511
+ */
512
+ getStats() {
513
+ const idleWorkers = this.workers.filter(w => w.state === WorkerState.IDLE).length;
514
+ const busyWorkers = this.workers.filter(w => w.state === WorkerState.BUSY).length;
515
+ const markedForRemoval = this.workers.filter(w => w.markedForRemoval).length;
516
+
517
+ return {
518
+ totalWorkers: this.workers.length,
519
+ targetWorkers: this.config.numWorkers,
520
+ idleWorkers,
521
+ busyWorkers,
522
+ markedForRemoval,
523
+ queuedTasks: this.taskQueue.length,
524
+ activeTasks: this.timeoutManager.getActiveCount(),
525
+ sharedMemory: this.shmManager.getStats(),
526
+ config: this.config
527
+ };
528
+ }
529
+ }
530
+
531
+ module.exports = {
532
+ WorkerPool
533
+ };