@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,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Memory Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages shared memory file lifecycle for large buffer transfer.
|
|
5
|
+
* Handles writing buffers to /dev/shm (Linux) or os.tmpdir(), tracking attachments,
|
|
6
|
+
* and cleanup coordination across task lifecycle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs').promises;
|
|
10
|
+
const fsSync = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
const SHARED_SENTINEL_KEY = '__rosepetal_shm_path__';
|
|
16
|
+
const SHARED_BASE64_KEY = '__rosepetal_base64__';
|
|
17
|
+
|
|
18
|
+
class SharedMemoryManager {
|
|
19
|
+
/**
|
|
20
|
+
* Create a shared memory manager
|
|
21
|
+
* @param {object} options - Configuration options
|
|
22
|
+
*/
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.threshold = options.threshold ?? 100 * 1024; // 100KB default
|
|
25
|
+
this.shmPath = this.detectShmPath();
|
|
26
|
+
this.trackAttachments = options.trackAttachments !== false;
|
|
27
|
+
this.taskAttachments = new Map(); // taskId → Set<filePath>
|
|
28
|
+
this.globalAttachments = new Set(); // All active files
|
|
29
|
+
this.performanceMetrics = {
|
|
30
|
+
totalBytes: 0,
|
|
31
|
+
totalFiles: 0,
|
|
32
|
+
filesCreated: 0,
|
|
33
|
+
filesDeleted: 0
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Cleanup orphaned files from previous crashes
|
|
37
|
+
if (options.cleanupOrphanedFiles !== false) {
|
|
38
|
+
this.cleanupOrphanedFiles();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Detect platform-specific shared memory path
|
|
44
|
+
* @returns {string} Path to shared memory directory
|
|
45
|
+
*/
|
|
46
|
+
detectShmPath() {
|
|
47
|
+
// Linux: /dev/shm (RAM-backed tmpfs)
|
|
48
|
+
const shmPath = '/dev/shm';
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Check if /dev/shm exists and is writable
|
|
52
|
+
fsSync.accessSync(shmPath, fsSync.constants.W_OK);
|
|
53
|
+
return shmPath;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Fall back to os.tmpdir() (macOS, Windows, or Linux without /dev/shm)
|
|
56
|
+
return os.tmpdir();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Generate unique collision-resistant filename
|
|
62
|
+
* @param {number|string} taskId - Task identifier
|
|
63
|
+
* @param {number} bufferIndex - Buffer index within message
|
|
64
|
+
* @returns {string} Unique filename
|
|
65
|
+
*/
|
|
66
|
+
generateFilename(taskId, bufferIndex) {
|
|
67
|
+
const pid = process.pid;
|
|
68
|
+
const timestamp = Date.now();
|
|
69
|
+
const random = crypto.randomBytes(2).toString('hex');
|
|
70
|
+
|
|
71
|
+
// Format: rosepetal-async-{pid}-{taskId}-{bufferIndex}-{timestamp}-{random}.bin
|
|
72
|
+
return `rosepetal-async-${pid}-${taskId}-${bufferIndex}-${timestamp}-${random}.bin`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Write buffer to shared memory
|
|
77
|
+
* @param {Buffer} buffer - Buffer to write
|
|
78
|
+
* @param {number|string} taskId - Task identifier
|
|
79
|
+
* @param {number} bufferIndex - Buffer index within message
|
|
80
|
+
* @returns {Promise<object>} Descriptor object
|
|
81
|
+
*/
|
|
82
|
+
async writeBuffer(buffer, taskId, bufferIndex) {
|
|
83
|
+
// Check if buffer exceeds threshold
|
|
84
|
+
if (this.threshold > 0 && buffer.length <= this.threshold) {
|
|
85
|
+
// Return inline buffer (no shared memory needed)
|
|
86
|
+
return buffer;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const filename = this.generateFilename(taskId, bufferIndex);
|
|
91
|
+
const filePath = path.join(this.shmPath, filename);
|
|
92
|
+
|
|
93
|
+
// Write buffer to file asynchronously
|
|
94
|
+
await fs.writeFile(filePath, buffer);
|
|
95
|
+
|
|
96
|
+
// Track attachment
|
|
97
|
+
this.trackAttachment(taskId, filePath);
|
|
98
|
+
|
|
99
|
+
// Update metrics
|
|
100
|
+
this.performanceMetrics.totalBytes += buffer.length;
|
|
101
|
+
this.performanceMetrics.totalFiles++;
|
|
102
|
+
this.performanceMetrics.filesCreated++;
|
|
103
|
+
|
|
104
|
+
// Return descriptor
|
|
105
|
+
return {
|
|
106
|
+
[SHARED_SENTINEL_KEY]: filePath,
|
|
107
|
+
length: buffer.length
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// Fallback to base64 (slower, but keeps payload serializable)
|
|
112
|
+
try {
|
|
113
|
+
return {
|
|
114
|
+
[SHARED_BASE64_KEY]: buffer.toString('base64'),
|
|
115
|
+
length: buffer.length
|
|
116
|
+
};
|
|
117
|
+
} catch (_encodeErr) {
|
|
118
|
+
// Last resort: inline buffer
|
|
119
|
+
return buffer;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read buffer from shared memory
|
|
126
|
+
* @param {object} descriptor - Buffer descriptor
|
|
127
|
+
* @returns {Promise<Buffer>} Buffer contents
|
|
128
|
+
*/
|
|
129
|
+
async readBuffer(descriptor, options = {}) {
|
|
130
|
+
// Validate descriptor
|
|
131
|
+
if (!descriptor || typeof descriptor !== 'object') {
|
|
132
|
+
throw new Error('Invalid descriptor: must be an object');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!descriptor[SHARED_SENTINEL_KEY]) {
|
|
136
|
+
throw new Error(`Invalid descriptor: missing ${SHARED_SENTINEL_KEY}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const filePath = descriptor[SHARED_SENTINEL_KEY];
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Read file asynchronously
|
|
143
|
+
const buffer = await fs.readFile(filePath);
|
|
144
|
+
|
|
145
|
+
// Validate length
|
|
146
|
+
if (descriptor.length && buffer.length !== descriptor.length) {
|
|
147
|
+
console.warn(`Buffer length mismatch: expected ${descriptor.length}, got ${buffer.length}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (options.deleteAfterRead) {
|
|
151
|
+
await fs.unlink(filePath).catch(unlinkErr => {
|
|
152
|
+
if (unlinkErr.code !== 'ENOENT') {
|
|
153
|
+
console.warn(`Failed to unlink shared memory file ${filePath}: ${unlinkErr.message}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return buffer;
|
|
159
|
+
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err.code === 'ENOENT') {
|
|
162
|
+
throw new Error(`Shared memory file not found: ${filePath}. File may have been cleaned up prematurely.`);
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`Failed to read buffer from shared memory: ${err.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Track attachment for task
|
|
170
|
+
* @param {number|string} taskId - Task identifier
|
|
171
|
+
* @param {string} filePath - File path to track
|
|
172
|
+
*/
|
|
173
|
+
trackAttachment(taskId, filePath) {
|
|
174
|
+
if (!this.trackAttachments) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Create task attachment set if doesn't exist
|
|
179
|
+
if (!this.taskAttachments.has(taskId)) {
|
|
180
|
+
this.taskAttachments.set(taskId, new Set());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Add to task-specific set
|
|
184
|
+
this.taskAttachments.get(taskId).add(filePath);
|
|
185
|
+
|
|
186
|
+
// Add to global set
|
|
187
|
+
this.globalAttachments.add(filePath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Cleanup task attachments
|
|
192
|
+
* @param {number|string} taskId - Task identifier
|
|
193
|
+
* @returns {Promise<void>}
|
|
194
|
+
*/
|
|
195
|
+
async cleanupTask(taskId) {
|
|
196
|
+
const attachments = this.taskAttachments.get(taskId);
|
|
197
|
+
|
|
198
|
+
if (!attachments || attachments.size === 0) {
|
|
199
|
+
return; // Nothing to cleanup
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Delete all files for this task
|
|
203
|
+
const deletePromises = [];
|
|
204
|
+
for (const filePath of attachments) {
|
|
205
|
+
deletePromises.push(
|
|
206
|
+
fs.unlink(filePath)
|
|
207
|
+
.then(() => {
|
|
208
|
+
this.globalAttachments.delete(filePath);
|
|
209
|
+
this.performanceMetrics.totalFiles = Math.max(0, this.performanceMetrics.totalFiles - 1);
|
|
210
|
+
this.performanceMetrics.filesDeleted++;
|
|
211
|
+
})
|
|
212
|
+
.catch(err => {
|
|
213
|
+
if (err.code === 'ENOENT') {
|
|
214
|
+
// File already cleaned up (e.g. worker deleted after read)
|
|
215
|
+
this.globalAttachments.delete(filePath);
|
|
216
|
+
this.performanceMetrics.totalFiles = Math.max(0, this.performanceMetrics.totalFiles - 1);
|
|
217
|
+
this.performanceMetrics.filesDeleted++;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.warn(`Failed to cleanup file ${filePath}: ${err.message}`);
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await Promise.all(deletePromises);
|
|
227
|
+
|
|
228
|
+
// Remove task entry
|
|
229
|
+
this.taskAttachments.delete(taskId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Cleanup all attachments (shutdown)
|
|
234
|
+
* @returns {Promise<void>}
|
|
235
|
+
*/
|
|
236
|
+
async cleanupAll() {
|
|
237
|
+
// Delete all tracked files
|
|
238
|
+
const deletePromises = [];
|
|
239
|
+
for (const filePath of this.globalAttachments) {
|
|
240
|
+
deletePromises.push(
|
|
241
|
+
fs.unlink(filePath).catch(err => {
|
|
242
|
+
if (err.code !== 'ENOENT') {
|
|
243
|
+
console.warn(`Failed to cleanup file ${filePath}: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await Promise.all(deletePromises);
|
|
250
|
+
|
|
251
|
+
// Clear all maps
|
|
252
|
+
this.taskAttachments.clear();
|
|
253
|
+
this.globalAttachments.clear();
|
|
254
|
+
|
|
255
|
+
// Reset metrics
|
|
256
|
+
this.performanceMetrics.totalFiles = 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Cleanup orphaned files from previous crashes
|
|
261
|
+
*/
|
|
262
|
+
cleanupOrphanedFiles() {
|
|
263
|
+
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const files = fsSync.readdirSync(this.shmPath);
|
|
267
|
+
|
|
268
|
+
for (const file of files) {
|
|
269
|
+
// Skip files that don't match our pattern
|
|
270
|
+
if (!file.startsWith('rosepetal-async-')) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const filePath = path.join(this.shmPath, file);
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const stats = fsSync.statSync(filePath);
|
|
278
|
+
|
|
279
|
+
// Only cleanup files older than 1 hour
|
|
280
|
+
if (stats.mtimeMs < oneHourAgo) {
|
|
281
|
+
fsSync.unlinkSync(filePath);
|
|
282
|
+
console.log(`Cleaned up orphaned file: ${file}`);
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// Ignore errors for individual files
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
// Ignore errors during startup cleanup
|
|
290
|
+
console.warn(`Failed to cleanup orphaned files: ${err.message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get statistics
|
|
296
|
+
* @returns {object} Statistics object
|
|
297
|
+
*/
|
|
298
|
+
getStats() {
|
|
299
|
+
return {
|
|
300
|
+
activeTasks: this.taskAttachments.size,
|
|
301
|
+
activeFiles: this.globalAttachments.size,
|
|
302
|
+
threshold: this.threshold,
|
|
303
|
+
shmPath: this.shmPath,
|
|
304
|
+
...this.performanceMetrics
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
SharedMemoryManager
|
|
311
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout Manager
|
|
3
|
+
*
|
|
4
|
+
* Tracks timeouts for async tasks and invokes callbacks when timeouts expire.
|
|
5
|
+
* Provides centralized timeout management for the worker pool.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class TimeoutManager {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.timeouts = new Map(); // taskId → { handle, callback, startTime }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Start a timeout for a task
|
|
15
|
+
*
|
|
16
|
+
* @param {number|string} taskId - Unique task identifier
|
|
17
|
+
* @param {number} duration - Timeout duration in milliseconds
|
|
18
|
+
* @param {Function} onTimeout - Callback to invoke on timeout
|
|
19
|
+
* @returns {void}
|
|
20
|
+
*/
|
|
21
|
+
startTimeout(taskId, duration, onTimeout) {
|
|
22
|
+
// Cancel existing timeout if any
|
|
23
|
+
this.cancelTimeout(taskId);
|
|
24
|
+
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
const handle = setTimeout(() => {
|
|
27
|
+
this.timeouts.delete(taskId);
|
|
28
|
+
onTimeout(taskId);
|
|
29
|
+
}, duration);
|
|
30
|
+
|
|
31
|
+
this.timeouts.set(taskId, {
|
|
32
|
+
handle,
|
|
33
|
+
callback: onTimeout,
|
|
34
|
+
startTime,
|
|
35
|
+
duration
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Cancel a timeout for a task
|
|
41
|
+
*
|
|
42
|
+
* @param {number|string} taskId - Task identifier
|
|
43
|
+
* @returns {boolean} True if timeout was cancelled, false if not found
|
|
44
|
+
*/
|
|
45
|
+
cancelTimeout(taskId) {
|
|
46
|
+
const timeout = this.timeouts.get(taskId);
|
|
47
|
+
if (timeout) {
|
|
48
|
+
clearTimeout(timeout.handle);
|
|
49
|
+
this.timeouts.delete(taskId);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if a timeout exists for a task
|
|
57
|
+
*
|
|
58
|
+
* @param {number|string} taskId - Task identifier
|
|
59
|
+
* @returns {boolean} True if timeout exists
|
|
60
|
+
*/
|
|
61
|
+
hasTimeout(taskId) {
|
|
62
|
+
return this.timeouts.has(taskId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get elapsed time for a task
|
|
67
|
+
*
|
|
68
|
+
* @param {number|string} taskId - Task identifier
|
|
69
|
+
* @returns {number|null} Elapsed time in milliseconds, or null if not found
|
|
70
|
+
*/
|
|
71
|
+
getElapsedTime(taskId) {
|
|
72
|
+
const timeout = this.timeouts.get(taskId);
|
|
73
|
+
if (timeout) {
|
|
74
|
+
return Date.now() - timeout.startTime;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get remaining time for a task
|
|
81
|
+
*
|
|
82
|
+
* @param {number|string} taskId - Task identifier
|
|
83
|
+
* @returns {number|null} Remaining time in milliseconds, or null if not found
|
|
84
|
+
*/
|
|
85
|
+
getRemainingTime(taskId) {
|
|
86
|
+
const timeout = this.timeouts.get(taskId);
|
|
87
|
+
if (timeout) {
|
|
88
|
+
const elapsed = Date.now() - timeout.startTime;
|
|
89
|
+
return Math.max(0, timeout.duration - elapsed);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get number of active timeouts
|
|
96
|
+
*
|
|
97
|
+
* @returns {number} Number of active timeouts
|
|
98
|
+
*/
|
|
99
|
+
getActiveCount() {
|
|
100
|
+
return this.timeouts.size;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Clear all timeouts
|
|
105
|
+
*
|
|
106
|
+
* @returns {void}
|
|
107
|
+
*/
|
|
108
|
+
clear() {
|
|
109
|
+
for (const timeout of this.timeouts.values()) {
|
|
110
|
+
clearTimeout(timeout.handle);
|
|
111
|
+
}
|
|
112
|
+
this.timeouts.clear();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get statistics about timeouts
|
|
117
|
+
*
|
|
118
|
+
* @returns {object} Statistics object
|
|
119
|
+
*/
|
|
120
|
+
getStats() {
|
|
121
|
+
const stats = {
|
|
122
|
+
activeCount: this.timeouts.size,
|
|
123
|
+
tasks: []
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (const [taskId, timeout] of this.timeouts.entries()) {
|
|
127
|
+
stats.tasks.push({
|
|
128
|
+
taskId,
|
|
129
|
+
elapsed: Date.now() - timeout.startTime,
|
|
130
|
+
remaining: Math.max(0, timeout.duration - (Date.now() - timeout.startTime)),
|
|
131
|
+
duration: timeout.duration
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return stats;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = TimeoutManager;
|