@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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Thread Script
|
|
3
|
+
*
|
|
4
|
+
* Executes user-provided JavaScript code in an isolated worker thread.
|
|
5
|
+
* Handles message communication with the main thread.
|
|
6
|
+
* Restores buffers from shared memory before execution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { parentPort, workerData } = require('worker_threads');
|
|
10
|
+
const { SharedMemoryManager } = require('./shared-memory-manager');
|
|
11
|
+
const { AsyncMessageSerializer } = require('./message-serializer');
|
|
12
|
+
|
|
13
|
+
// Track worker state
|
|
14
|
+
let isTerminating = false;
|
|
15
|
+
|
|
16
|
+
function hrtimeDiffToMs(start) {
|
|
17
|
+
if (typeof start !== 'bigint') {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
const diff = process.hrtime.bigint() - start;
|
|
21
|
+
return Number(diff) / 1e6;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Shared memory management (for buffer restoration + result encoding)
|
|
25
|
+
const shmManager = new SharedMemoryManager({
|
|
26
|
+
threshold: workerData && typeof workerData.shmThreshold === 'number' ? workerData.shmThreshold : undefined,
|
|
27
|
+
trackAttachments: false,
|
|
28
|
+
cleanupOrphanedFiles: false
|
|
29
|
+
});
|
|
30
|
+
const serializer = new AsyncMessageSerializer(shmManager);
|
|
31
|
+
|
|
32
|
+
// Cache compiled user code per worker for hot-path performance
|
|
33
|
+
// Bounded LRU-style cache to prevent memory leaks with varied code inputs
|
|
34
|
+
const AsyncFunction = (async function() {}).constructor;
|
|
35
|
+
const MAX_CACHE_SIZE = 100;
|
|
36
|
+
const compiledCodeCache = new Map(); // code string -> AsyncFunction(msg, ...modules) { ... }
|
|
37
|
+
|
|
38
|
+
function getCachedFunction(cacheKey) {
|
|
39
|
+
const fn = compiledCodeCache.get(cacheKey);
|
|
40
|
+
if (fn) {
|
|
41
|
+
// Move to end for LRU behavior (delete + re-add)
|
|
42
|
+
compiledCodeCache.delete(cacheKey);
|
|
43
|
+
compiledCodeCache.set(cacheKey, fn);
|
|
44
|
+
}
|
|
45
|
+
return fn;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setCachedFunction(cacheKey, fn) {
|
|
49
|
+
// Evict oldest entries if at capacity
|
|
50
|
+
if (compiledCodeCache.size >= MAX_CACHE_SIZE) {
|
|
51
|
+
const oldestKey = compiledCodeCache.keys().next().value;
|
|
52
|
+
compiledCodeCache.delete(oldestKey);
|
|
53
|
+
}
|
|
54
|
+
compiledCodeCache.set(cacheKey, fn);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Load configured external modules
|
|
58
|
+
const loadedModules = {};
|
|
59
|
+
const moduleVars = [];
|
|
60
|
+
const moduleValues = [];
|
|
61
|
+
|
|
62
|
+
// Add Node-RED user directory to module search path for external modules
|
|
63
|
+
if (workerData && workerData.nodeRedUserDir) {
|
|
64
|
+
const path = require('path');
|
|
65
|
+
const nodeModulesPath = path.join(workerData.nodeRedUserDir, 'node_modules');
|
|
66
|
+
if (!module.paths.includes(nodeModulesPath)) {
|
|
67
|
+
module.paths.unshift(nodeModulesPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (workerData && workerData.libs && Array.isArray(workerData.libs)) {
|
|
72
|
+
for (const lib of workerData.libs) {
|
|
73
|
+
if (lib.module && lib.var) {
|
|
74
|
+
try {
|
|
75
|
+
loadedModules[lib.var] = require(lib.module);
|
|
76
|
+
moduleVars.push(lib.var);
|
|
77
|
+
moduleValues.push(loadedModules[lib.var]);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`[async-function] Failed to load module ${lib.module}: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Handle incoming messages from main thread
|
|
87
|
+
*/
|
|
88
|
+
if (parentPort) {
|
|
89
|
+
parentPort.on('message', async (data) => {
|
|
90
|
+
// Ignore messages if terminating
|
|
91
|
+
if (isTerminating) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { type, taskId, code, msg } = data;
|
|
96
|
+
|
|
97
|
+
// Handle different message types
|
|
98
|
+
if (type === 'execute') {
|
|
99
|
+
try {
|
|
100
|
+
// Restore + execute user code
|
|
101
|
+
const restoreStart = process.hrtime.bigint();
|
|
102
|
+
const restoredMsg = await serializer.restoreBuffers(msg);
|
|
103
|
+
const transferToPythonMs = hrtimeDiffToMs(restoreStart);
|
|
104
|
+
|
|
105
|
+
// Cache key includes code + module vars to handle different module configs
|
|
106
|
+
const cacheKey = code + '|' + moduleVars.join(',');
|
|
107
|
+
let userFunction = getCachedFunction(cacheKey);
|
|
108
|
+
if (!userFunction) {
|
|
109
|
+
// Create function with msg + all module variables as parameters
|
|
110
|
+
userFunction = new AsyncFunction('msg', ...moduleVars, code);
|
|
111
|
+
setCachedFunction(cacheKey, userFunction);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const execStart = process.hrtime.bigint();
|
|
115
|
+
// Execute with msg and all loaded module values
|
|
116
|
+
const rawResult = await userFunction(restoredMsg, ...moduleValues);
|
|
117
|
+
const executionMs = hrtimeDiffToMs(execStart);
|
|
118
|
+
|
|
119
|
+
// Offload buffers in the result (large Buffers -> shared memory descriptors)
|
|
120
|
+
const encodeStart = process.hrtime.bigint();
|
|
121
|
+
const encodedResult = await serializer.sanitizeMessage(rawResult, null, taskId);
|
|
122
|
+
const transferToJsMs = hrtimeDiffToMs(encodeStart);
|
|
123
|
+
|
|
124
|
+
// Send result back to main thread
|
|
125
|
+
parentPort.postMessage({
|
|
126
|
+
type: 'result',
|
|
127
|
+
taskId,
|
|
128
|
+
result: encodedResult,
|
|
129
|
+
performance: {
|
|
130
|
+
transfer_to_python_ms: transferToPythonMs,
|
|
131
|
+
execution_ms: executionMs,
|
|
132
|
+
transfer_to_js_ms: transferToJsMs
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Send error back to main thread
|
|
138
|
+
parentPort.postMessage({
|
|
139
|
+
type: 'error',
|
|
140
|
+
taskId,
|
|
141
|
+
error: {
|
|
142
|
+
message: err.message,
|
|
143
|
+
stack: err.stack,
|
|
144
|
+
name: err.name
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
} else if (type === 'terminate') {
|
|
149
|
+
// Graceful termination requested
|
|
150
|
+
isTerminating = true;
|
|
151
|
+
parentPort.postMessage({
|
|
152
|
+
type: 'terminated',
|
|
153
|
+
taskId
|
|
154
|
+
});
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Handle errors
|
|
160
|
+
process.on('uncaughtException', (err) => {
|
|
161
|
+
if (!isTerminating) {
|
|
162
|
+
parentPort.postMessage({
|
|
163
|
+
type: 'error',
|
|
164
|
+
taskId: null,
|
|
165
|
+
error: {
|
|
166
|
+
message: `Uncaught exception: ${err.message}`,
|
|
167
|
+
stack: err.stack,
|
|
168
|
+
name: err.name
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
process.on('unhandledRejection', (reason, _promise) => {
|
|
175
|
+
if (!isTerminating) {
|
|
176
|
+
parentPort.postMessage({
|
|
177
|
+
type: 'error',
|
|
178
|
+
taskId: null,
|
|
179
|
+
error: {
|
|
180
|
+
message: `Unhandled rejection: ${reason}`,
|
|
181
|
+
stack: reason?.stack || '',
|
|
182
|
+
name: 'UnhandledRejection'
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Signal ready
|
|
189
|
+
parentPort.postMessage({
|
|
190
|
+
type: 'ready'
|
|
191
|
+
});
|
|
192
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rosepetal/node-red-contrib-async-function",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A Node-RED function node that runs code in worker threads to keep your flows responsive",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/rosepetal-ai/node-red-contrib-async-function.git"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"node-red",
|
|
11
|
+
"function",
|
|
12
|
+
"async",
|
|
13
|
+
"worker-threads",
|
|
14
|
+
"non-blocking",
|
|
15
|
+
"performance",
|
|
16
|
+
"rosepetal"
|
|
17
|
+
],
|
|
18
|
+
"author": "Rosepetal SL (https://www.rosepetal.ai)",
|
|
19
|
+
"contributors": [
|
|
20
|
+
{
|
|
21
|
+
"name": "Nil Allue",
|
|
22
|
+
"email": "nil.allue@rosepetal.ai"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/rosepetal-ai/node-red-contrib-async-function/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/rosepetal-ai/node-red-contrib-async-function#readme",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
},
|
|
34
|
+
"node-red": {
|
|
35
|
+
"version": ">=2.0.0",
|
|
36
|
+
"nodes": {
|
|
37
|
+
"async-function": "nodes/async-function.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {}
|
|
41
|
+
}
|