@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,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;