@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.
- package/LICENSE +13 -0
- package/README.md +213 -0
- package/assets/example.png +0 -0
- package/nodes/async-function.html +600 -0
- package/nodes/async-function.js +351 -0
- package/nodes/lib/message-serializer.js +407 -0
- package/nodes/lib/module-installer.js +105 -0
- package/nodes/lib/shared-memory-manager.js +311 -0
- package/nodes/lib/timeout-manager.js +139 -0
- package/nodes/lib/worker-pool.js +533 -0
- package/nodes/lib/worker-script.js +192 -0
- package/package.json +41 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async Function Node
|
|
3
|
+
*
|
|
4
|
+
* A Node-RED function node that executes user code in worker threads
|
|
5
|
+
* to prevent event loop blocking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { WorkerPool } = require('./lib/worker-pool');
|
|
9
|
+
const { installModule, getNodeRedUserDir } = require('./lib/module-installer');
|
|
10
|
+
|
|
11
|
+
function extractMsgKeysFromCode(code) {
|
|
12
|
+
const keys = new Set();
|
|
13
|
+
if (typeof code !== 'string' || !code.trim()) {
|
|
14
|
+
return keys;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// msg['payload'] or msg["payload"]
|
|
18
|
+
const bracketRegex = /msg\[['"]([A-Za-z0-9_.$:-]+)['"]\]/g;
|
|
19
|
+
let match = bracketRegex.exec(code);
|
|
20
|
+
while (match) {
|
|
21
|
+
keys.add(match[1]);
|
|
22
|
+
match = bracketRegex.exec(code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// msg.payload style access
|
|
26
|
+
const dotRegex = /msg\.([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
27
|
+
match = dotRegex.exec(code);
|
|
28
|
+
while (match) {
|
|
29
|
+
keys.add(match[1]);
|
|
30
|
+
match = dotRegex.exec(code);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return keys;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildWorkerInputMsg(originalMsg, code) {
|
|
37
|
+
if (!originalMsg || typeof originalMsg !== 'object') {
|
|
38
|
+
return originalMsg;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const keys = extractMsgKeysFromCode(code);
|
|
42
|
+
|
|
43
|
+
// If we cannot confidently determine keys, fall back to full message
|
|
44
|
+
if (!keys || keys.size === 0) {
|
|
45
|
+
return originalMsg;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const subset = {};
|
|
49
|
+
keys.forEach((key) => {
|
|
50
|
+
if (Object.prototype.hasOwnProperty.call(originalMsg, key)) {
|
|
51
|
+
subset[key] = originalMsg[key];
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Preserve _msgid for traceability
|
|
56
|
+
if (Object.prototype.hasOwnProperty.call(originalMsg, '_msgid') && !Object.prototype.hasOwnProperty.call(subset, '_msgid')) {
|
|
57
|
+
subset._msgid = originalMsg._msgid;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return subset;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizePerformanceValue(value) {
|
|
64
|
+
const numeric = Number(value);
|
|
65
|
+
return Number.isFinite(numeric) ? numeric : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hrtimeDiffToMs(start) {
|
|
69
|
+
if (typeof start !== 'bigint') {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
const diff = process.hrtime.bigint() - start;
|
|
73
|
+
return Number(diff) / 1e6;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyPerformanceMetrics(node, originalMsg, targetMsg, performance) {
|
|
77
|
+
if (!performance || typeof performance !== 'object') {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const label = (typeof node.name === 'string' && node.name.trim()) ? node.name.trim() : 'async function';
|
|
82
|
+
if (!label) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const copyPerformance = (source, destination) => {
|
|
87
|
+
if (source && typeof source === 'object' && !Array.isArray(source)) {
|
|
88
|
+
Object.keys(source).forEach((key) => {
|
|
89
|
+
destination[key] = source[key];
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const collected = {};
|
|
95
|
+
if (originalMsg && originalMsg !== targetMsg) {
|
|
96
|
+
copyPerformance(originalMsg.performance, collected);
|
|
97
|
+
}
|
|
98
|
+
copyPerformance(targetMsg.performance, collected);
|
|
99
|
+
|
|
100
|
+
collected[label] = {
|
|
101
|
+
transferToPythonMs: normalizePerformanceValue(performance.transfer_to_python_ms ?? performance.transferToPythonMs),
|
|
102
|
+
executionMs: normalizePerformanceValue(performance.execution_ms ?? performance.executionMs),
|
|
103
|
+
transferToJsMs: normalizePerformanceValue(performance.transfer_to_js_ms ?? performance.transferToJsMs),
|
|
104
|
+
totalMs: normalizePerformanceValue(performance.totalMs ?? performance.total_ms ?? performance.total)
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
targetMsg.performance = collected;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function mergeResult(originalMsg, resultData) {
|
|
111
|
+
if (resultData === null || resultData === undefined) {
|
|
112
|
+
return resultData;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (Array.isArray(resultData)) {
|
|
116
|
+
return resultData.map((entry) => mergeResult(originalMsg, entry));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof resultData === 'object') {
|
|
120
|
+
return Object.assign({}, originalMsg, resultData);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return resultData;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyPerformanceToResult(node, originalMsg, resultData, performance) {
|
|
127
|
+
if (resultData === null || resultData === undefined) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (Array.isArray(resultData)) {
|
|
132
|
+
resultData.forEach((entry) => applyPerformanceToResult(node, originalMsg, entry, performance));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof resultData === 'object') {
|
|
137
|
+
applyPerformanceMetrics(node, originalMsg, resultData, performance);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Update node status with pool statistics
|
|
143
|
+
*/
|
|
144
|
+
function updateStatus(node) {
|
|
145
|
+
if (!node.pool) return;
|
|
146
|
+
|
|
147
|
+
const stats = node.pool.getStats();
|
|
148
|
+
|
|
149
|
+
// Format: "Active: 2/4 | Queue: 5 | SHM: 3" (SHM only shown if >0 files)
|
|
150
|
+
let statusText = `Active: ${stats.busyWorkers}/${stats.totalWorkers} | Queue: ${stats.queuedTasks}`;
|
|
151
|
+
if (stats.sharedMemory && stats.sharedMemory.activeFiles > 0) {
|
|
152
|
+
statusText += ` | SHM: ${stats.sharedMemory.activeFiles}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Color logic
|
|
156
|
+
let fill = 'green';
|
|
157
|
+
let shape = 'dot';
|
|
158
|
+
|
|
159
|
+
if (stats.queuedTasks > 50) {
|
|
160
|
+
fill = 'yellow'; // Queue getting full
|
|
161
|
+
}
|
|
162
|
+
if (stats.queuedTasks >= stats.config.maxQueueSize * 0.9) {
|
|
163
|
+
fill = 'red'; // Queue almost full
|
|
164
|
+
}
|
|
165
|
+
if (stats.busyWorkers === stats.totalWorkers && stats.queuedTasks > 0) {
|
|
166
|
+
shape = 'ring'; // All workers busy + queue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
node.status({
|
|
170
|
+
fill: fill,
|
|
171
|
+
shape: shape,
|
|
172
|
+
text: statusText
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = function(RED) {
|
|
177
|
+
/**
|
|
178
|
+
* Async Function Node Constructor
|
|
179
|
+
* @param {object} config - Node configuration
|
|
180
|
+
*/
|
|
181
|
+
function AsyncFunctionNode(config) {
|
|
182
|
+
RED.nodes.createNode(this, config);
|
|
183
|
+
const node = this;
|
|
184
|
+
|
|
185
|
+
// Store configuration
|
|
186
|
+
node.func = config.func || 'return msg;';
|
|
187
|
+
node.outputs = config.outputs || 1;
|
|
188
|
+
node.timeout = config.timeout || 30000;
|
|
189
|
+
node.name = config.name || '';
|
|
190
|
+
node.errorRecoveryTimer = null; // Track error recovery timer
|
|
191
|
+
|
|
192
|
+
// Migrate old config to new format (backwards compatibility)
|
|
193
|
+
if (config.minWorkers !== undefined || config.maxWorkers !== undefined) {
|
|
194
|
+
config.numWorkers = config.maxWorkers || config.minWorkers || 3;
|
|
195
|
+
node.warn('Configuration migrated: minWorkers/maxWorkers → numWorkers=' + config.numWorkers);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Store libs configuration
|
|
199
|
+
node.libs = config.libs || [];
|
|
200
|
+
|
|
201
|
+
// Pre-check and install missing modules
|
|
202
|
+
if (node.libs && node.libs.length > 0) {
|
|
203
|
+
for (const lib of node.libs) {
|
|
204
|
+
try {
|
|
205
|
+
require.resolve(lib.module);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (err.code === 'MODULE_NOT_FOUND') {
|
|
208
|
+
node.warn(`Installing missing module: ${lib.module}`);
|
|
209
|
+
if (!installModule(lib.module)) {
|
|
210
|
+
node.error(`Failed to install module: ${lib.module}. Please install it manually in ~/.node-red`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Create per-node worker pool
|
|
218
|
+
try {
|
|
219
|
+
node.pool = new WorkerPool({
|
|
220
|
+
numWorkers: config.numWorkers || 3,
|
|
221
|
+
maxQueueSize: config.maxQueueSize || 100,
|
|
222
|
+
taskTimeout: node.timeout,
|
|
223
|
+
shmThreshold: 0,
|
|
224
|
+
libs: node.libs,
|
|
225
|
+
nodeRedUserDir: getNodeRedUserDir()
|
|
226
|
+
});
|
|
227
|
+
} catch (err) {
|
|
228
|
+
node.error('Failed to create worker pool: ' + err.message);
|
|
229
|
+
node.status({
|
|
230
|
+
fill: 'red',
|
|
231
|
+
shape: 'dot',
|
|
232
|
+
text: 'Pool creation failed'
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Initialize pool
|
|
238
|
+
node.pool.initialize().then(() => {
|
|
239
|
+
updateStatus(node);
|
|
240
|
+
// Start periodic status updates
|
|
241
|
+
node.statusInterval = setInterval(() => {
|
|
242
|
+
updateStatus(node);
|
|
243
|
+
}, 2000); // Update every 2 seconds
|
|
244
|
+
}).catch(err => {
|
|
245
|
+
node.error('Failed to initialize worker pool: ' + err.message);
|
|
246
|
+
node.status({
|
|
247
|
+
fill: 'red',
|
|
248
|
+
shape: 'dot',
|
|
249
|
+
text: 'Init failed'
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Handle incoming messages
|
|
254
|
+
node.on('input', async function(msg, send, done) {
|
|
255
|
+
// Backwards compatibility with older Node-RED versions
|
|
256
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
257
|
+
done = done || function(err) {
|
|
258
|
+
if (err) {
|
|
259
|
+
node.error(err, msg);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const timing = { start: process.hrtime.bigint() };
|
|
264
|
+
const workerMsg = buildWorkerInputMsg(msg, node.func);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const payload = await node.pool.executeTask(node.func, workerMsg, node.timeout);
|
|
268
|
+
|
|
269
|
+
let resultData;
|
|
270
|
+
let performanceData = null;
|
|
271
|
+
|
|
272
|
+
if (payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'result')) {
|
|
273
|
+
resultData = payload.result;
|
|
274
|
+
performanceData = payload.performance || null;
|
|
275
|
+
} else {
|
|
276
|
+
resultData = payload;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (resultData === null || resultData === undefined) {
|
|
280
|
+
done();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const totalMs = hrtimeDiffToMs(timing.start);
|
|
285
|
+
const mergedPerformance = Object.assign({}, performanceData || {});
|
|
286
|
+
mergedPerformance.totalMs = totalMs;
|
|
287
|
+
|
|
288
|
+
const output = mergeResult(msg, resultData);
|
|
289
|
+
applyPerformanceToResult(node, msg, output, mergedPerformance);
|
|
290
|
+
|
|
291
|
+
send(output);
|
|
292
|
+
done();
|
|
293
|
+
// Status updated by periodic interval (every 2s) - no per-message update needed
|
|
294
|
+
} catch (err) {
|
|
295
|
+
// Handle errors
|
|
296
|
+
node.status({
|
|
297
|
+
fill: 'red',
|
|
298
|
+
shape: 'dot',
|
|
299
|
+
text: `Error: ${err.message.substring(0, 20)}`
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Log error
|
|
303
|
+
node.error(`Async function error: ${err.message}`, msg);
|
|
304
|
+
|
|
305
|
+
// Propagate error to Catch node
|
|
306
|
+
done(err);
|
|
307
|
+
|
|
308
|
+
// Restore normal status after 3 seconds (clear any existing timer first)
|
|
309
|
+
if (node.errorRecoveryTimer) {
|
|
310
|
+
clearTimeout(node.errorRecoveryTimer);
|
|
311
|
+
}
|
|
312
|
+
node.errorRecoveryTimer = setTimeout(() => {
|
|
313
|
+
node.errorRecoveryTimer = null;
|
|
314
|
+
updateStatus(node);
|
|
315
|
+
}, 3000);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Handle node close
|
|
320
|
+
node.on('close', async function(removed, done) {
|
|
321
|
+
// Clear status interval
|
|
322
|
+
if (node.statusInterval) {
|
|
323
|
+
clearInterval(node.statusInterval);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Clear error recovery timer
|
|
327
|
+
if (node.errorRecoveryTimer) {
|
|
328
|
+
clearTimeout(node.errorRecoveryTimer);
|
|
329
|
+
node.errorRecoveryTimer = null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
node.status({});
|
|
333
|
+
|
|
334
|
+
// Shutdown per-node pool
|
|
335
|
+
if (node.pool) {
|
|
336
|
+
try {
|
|
337
|
+
await node.pool.shutdown();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
node.error('Error shutting down worker pool: ' + err.message);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (done) {
|
|
344
|
+
done();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Register the node type
|
|
350
|
+
RED.nodes.registerType('async-function', AsyncFunctionNode);
|
|
351
|
+
};
|