@inteli.city/node-red-contrib-exec-collection 1.0.3 → 1.0.5
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/README.md +836 -5
- package/async.pg.js +1 -0
- package/exec.queue.html +228 -38
- package/exec.queue.js +554 -538
- package/exec.service.html +342 -229
- package/exec.service.js +325 -487
- package/node.queue.html +359 -0
- package/node.queue.js +569 -0
- package/package.json +19 -19
- package/python.config.html +55 -0
- package/python.config.js +24 -0
- package/python.queue.html +360 -0
- package/python.queue.js +555 -0
- package/utils/context.js +54 -0
- package/async.gpt.html +0 -327
- package/async.gpt.js +0 -615
- package/async.latex.html +0 -319
- package/async.latex.js +0 -618
- package/module.njk.html +0 -45
- package/module.njk.js +0 -12
- package/template.njk.html +0 -201
- package/template.njk.js +0 -138
- package/thrd.function.html +0 -312
- package/thrd.function.js +0 -586
- package/thread.queue.html +0 -311
- package/thread.queue.js +0 -586
package/python.queue.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── requires ──────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const nunjucks = require('nunjucks');
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
const yaml = require('js-yaml');
|
|
8
|
+
const convertXML = require('xml-js');
|
|
9
|
+
|
|
10
|
+
// ── LOCAL TEMPLATE CONTEXT ────────────────────────────────────────────────────
|
|
11
|
+
// Raw context: objects are NOT pre-stringified. Instead, each value is wrapped
|
|
12
|
+
// in a Proxy that serialises itself only when Nunjucks coerces it to a string
|
|
13
|
+
// (i.e. at output time). This means:
|
|
14
|
+
// {{ payload }} → JSON.stringify(payload) (calls toString / toPrimitive)
|
|
15
|
+
// {{ payload.a }} → the value of a (string/number as-is, object → JSON)
|
|
16
|
+
// {{ payload.a.b }} → the value of b
|
|
17
|
+
// No "[object Object]" is ever produced.
|
|
18
|
+
|
|
19
|
+
const _SKIP_KEYS = ['req', 'res', '_req', '_res'];
|
|
20
|
+
|
|
21
|
+
function _safeStringify(obj) {
|
|
22
|
+
const seen = new WeakSet();
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(obj, (key, value) => {
|
|
25
|
+
if (typeof value === 'object' && value !== null) {
|
|
26
|
+
if (seen.has(value)) return '[Circular]';
|
|
27
|
+
seen.add(value);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
});
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return '[Unserializable]';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _wrapValue(value) {
|
|
37
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
38
|
+
return new Proxy(value, {
|
|
39
|
+
get(target, prop) {
|
|
40
|
+
if (prop === Symbol.toPrimitive) return () => _safeStringify(target);
|
|
41
|
+
if (prop === 'toString') return () => _safeStringify(target);
|
|
42
|
+
const result = Reflect.get(target, prop);
|
|
43
|
+
if (typeof result === 'object' && result !== null) return _wrapValue(result);
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRawContext(msg, node) {
|
|
50
|
+
const ctx = {};
|
|
51
|
+
for (const k of Object.keys(msg || {})) {
|
|
52
|
+
if (_SKIP_KEYS.includes(k)) continue;
|
|
53
|
+
ctx[k] = _wrapValue(msg[k]);
|
|
54
|
+
}
|
|
55
|
+
ctx.flow = { get: (key) => node.context().flow.get(key) };
|
|
56
|
+
ctx.global = { get: (key) => node.context().global.get(key) };
|
|
57
|
+
ctx.env = process.env;
|
|
58
|
+
return ctx;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── BOOTSTRAP ─────────────────────────────────────────────────────────────────
|
|
62
|
+
// Injected into each Python worker via -c.
|
|
63
|
+
// Reads one JSON line per execution: { "code": "...python..." }
|
|
64
|
+
// During execution stdout is intercepted; each write() emits a frame:
|
|
65
|
+
// { "t": "o", "v": "...chunk..." } — output chunk
|
|
66
|
+
// After execution:
|
|
67
|
+
// { "t": "d" } — done (success)
|
|
68
|
+
// { "t": "e", "v": "...traceback..." } — error
|
|
69
|
+
// Each worker has its own isolated namespace — no shared state between workers.
|
|
70
|
+
|
|
71
|
+
const PYTHON_BOOTSTRAP = `\
|
|
72
|
+
import sys as _sys, json as _json, traceback as _tb
|
|
73
|
+
|
|
74
|
+
class _Out(object):
|
|
75
|
+
def __init__(self, r): self._r = r; self._buf = ''
|
|
76
|
+
def write(self, s):
|
|
77
|
+
if not s: return
|
|
78
|
+
self._buf += s
|
|
79
|
+
while '\\n' in self._buf:
|
|
80
|
+
nl = self._buf.index('\\n')
|
|
81
|
+
chunk = self._buf[:nl+1]
|
|
82
|
+
self._buf = self._buf[nl+1:]
|
|
83
|
+
self._r.write(_json.dumps({'t':'o','v':chunk}) + '\\n')
|
|
84
|
+
self._r.flush()
|
|
85
|
+
def flush(self):
|
|
86
|
+
if self._buf:
|
|
87
|
+
self._r.write(_json.dumps({'t':'o','v':self._buf}) + '\\n')
|
|
88
|
+
self._r.flush()
|
|
89
|
+
self._buf = ''
|
|
90
|
+
|
|
91
|
+
_real = _sys.stdout
|
|
92
|
+
_ns = {}
|
|
93
|
+
while True:
|
|
94
|
+
_line = _sys.stdin.readline()
|
|
95
|
+
if not _line: break
|
|
96
|
+
_line = _line.rstrip('\\n')
|
|
97
|
+
if not _line: continue
|
|
98
|
+
_out = _Out(_real)
|
|
99
|
+
_sys.stdout = _out
|
|
100
|
+
try:
|
|
101
|
+
_d = _json.loads(_line)
|
|
102
|
+
exec(_d['code'], _ns)
|
|
103
|
+
_out.flush()
|
|
104
|
+
_real.write(_json.dumps({'t':'d'}) + '\\n')
|
|
105
|
+
_real.flush()
|
|
106
|
+
except Exception:
|
|
107
|
+
_out.flush()
|
|
108
|
+
_real.write(_json.dumps({'t':'e','v':_tb.format_exc()}) + '\\n')
|
|
109
|
+
_real.flush()
|
|
110
|
+
finally:
|
|
111
|
+
_sys.stdout = _real
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
// ── SECTION 1: TEMPLATE RESOLUTION ───────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function selectTemplate(msg, node) {
|
|
117
|
+
if (msg.hasOwnProperty('template') && (node.template === '' || node.template === null)) {
|
|
118
|
+
return msg.template;
|
|
119
|
+
}
|
|
120
|
+
return node.template;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
// ── SECTION 2: OUTPUT PARSING ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
// Pure: parses rawValue per outputFormat.
|
|
127
|
+
// Returns { value } on success, { error: string } on failure.
|
|
128
|
+
function parseOutputValue(rawValue, outputFormat) {
|
|
129
|
+
if (outputFormat === 'parsedJSON') {
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(rawValue);
|
|
132
|
+
if (typeof parsed === 'number') {
|
|
133
|
+
return { error: 'Error parsing JSON: result is a plain number' };
|
|
134
|
+
}
|
|
135
|
+
return { value: parsed };
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return { error: 'Error parsing JSON:\n\n' + err };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (outputFormat === 'parsedXML') {
|
|
142
|
+
try {
|
|
143
|
+
return { value: convertXML.xml2js(rawValue, { compact: true, spaces: 4 }) };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { error: 'Error parsing XML:\n\n' + err };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (outputFormat === 'parsedYAML') {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = yaml.load(rawValue);
|
|
152
|
+
if (typeof parsed === 'number') {
|
|
153
|
+
return { error: 'Error parsing YAML: result is a plain number' };
|
|
154
|
+
}
|
|
155
|
+
return { value: parsed };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return { error: 'Error parsing YAML:\n\n' + err };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { value: rawValue };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
// ── SECTION 3: NODE DEFINITION ────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
module.exports = function(RED) {
|
|
168
|
+
|
|
169
|
+
function PythonQueueNode(n) {
|
|
170
|
+
RED.nodes.createNode(this, n);
|
|
171
|
+
|
|
172
|
+
this.name = n.name;
|
|
173
|
+
this.template = n.template || '';
|
|
174
|
+
this.outputFormat = n.output || 'str';
|
|
175
|
+
this.field = n.field || 'payload';
|
|
176
|
+
this.fieldType = n.fieldType || 'msg';
|
|
177
|
+
this.queue = Math.max(1, Number(n.queue) || 1);
|
|
178
|
+
this.pythonConfig = RED.nodes.getNode(n.pythonConfig);
|
|
179
|
+
this.streamMode = n.streamMode || 'delimited';
|
|
180
|
+
|
|
181
|
+
// Interpret escape sequences in the stored delimiter string: \n → newline, \t → tab, \r → CR
|
|
182
|
+
const delimRaw = (n.delimiter !== undefined && n.delimiter !== '') ? n.delimiter : '\\n';
|
|
183
|
+
this.delimiter = delimRaw.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
|
|
184
|
+
|
|
185
|
+
const node = this;
|
|
186
|
+
const pythonPath = (node.pythonConfig && node.pythonConfig.pythonPath) || 'python3';
|
|
187
|
+
|
|
188
|
+
// ── worker pool ───────────────────────────────────────────────────────
|
|
189
|
+
// Each worker: { proc, busy, currentItem, lineBuffer }
|
|
190
|
+
let workers = [];
|
|
191
|
+
let pendingQueue = [];
|
|
192
|
+
let closing = false;
|
|
193
|
+
|
|
194
|
+
// ── status state ──────────────────────────────────────────────────────
|
|
195
|
+
let currentState = { waiting: 0, executing: 0, workers: 0 };
|
|
196
|
+
let lastEmittedState = null;
|
|
197
|
+
let statusTimer = null;
|
|
198
|
+
let lastEmitTime = 0;
|
|
199
|
+
|
|
200
|
+
const STATUS_INTERVAL_MS = 1000;
|
|
201
|
+
|
|
202
|
+
// ── idle timeout ──────────────────────────────────────────────────────
|
|
203
|
+
const IDLE_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes
|
|
204
|
+
let idleTimer = null;
|
|
205
|
+
|
|
206
|
+
// ── output helpers ────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
// Emit one segment: parse it and call send(). Never calls done().
|
|
209
|
+
function emitSegment(item, value) {
|
|
210
|
+
const result = parseOutputValue(value, node.outputFormat);
|
|
211
|
+
if (result.error) {
|
|
212
|
+
node.error(result.error);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (node.fieldType === 'msg') {
|
|
216
|
+
const outMsg = Object.assign({}, item.msg);
|
|
217
|
+
RED.util.setMessageProperty(outMsg, node.field, result.value);
|
|
218
|
+
item.send(outMsg);
|
|
219
|
+
} else {
|
|
220
|
+
const context = RED.util.parseContextStore(node.field);
|
|
221
|
+
const target = node.context()[node.fieldType];
|
|
222
|
+
const outMsg = Object.assign({}, item.msg);
|
|
223
|
+
target.set(context.key, result.value, context.store, (err) => {
|
|
224
|
+
if (err) node.error(err);
|
|
225
|
+
else item.send(outMsg);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Route an output chunk: raw → accumulate for single emit at done;
|
|
231
|
+
// delimited → buffer and split on delimiter.
|
|
232
|
+
function handleOutputChunk(item, chunk) {
|
|
233
|
+
if (node.streamMode === 'raw') {
|
|
234
|
+
item.rawBuffer += chunk;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
item.segmentBuffer += chunk;
|
|
238
|
+
const parts = item.segmentBuffer.split(node.delimiter);
|
|
239
|
+
item.segmentBuffer = parts.pop(); // keep incomplete remainder
|
|
240
|
+
for (const part of parts) {
|
|
241
|
+
if (part) emitSegment(item, part);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── stream frame handler ──────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
// Handles one JSON line from the worker's stdout.
|
|
248
|
+
// Frames: { t:'o', v:chunk } | { t:'d' } | { t:'e', v:traceback }
|
|
249
|
+
function handleStreamFrame(worker, line) {
|
|
250
|
+
let frame;
|
|
251
|
+
try {
|
|
252
|
+
frame = JSON.parse(line);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
node.error(`JSON parse error from Python: ${e.message}\nRaw: ${line}`);
|
|
255
|
+
if (worker.currentItem) {
|
|
256
|
+
const item = worker.currentItem;
|
|
257
|
+
worker.currentItem = null;
|
|
258
|
+
worker.busy = false;
|
|
259
|
+
item.done(e);
|
|
260
|
+
resetIdleTimer();
|
|
261
|
+
processNext();
|
|
262
|
+
computeAndStoreState();
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const item = worker.currentItem;
|
|
268
|
+
if (!item) return; // killed — ignore late frames silently
|
|
269
|
+
|
|
270
|
+
if (frame.t === 'o') {
|
|
271
|
+
handleOutputChunk(item, frame.v);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 'd' or 'e' — job is finished
|
|
276
|
+
worker.currentItem = null;
|
|
277
|
+
worker.busy = false;
|
|
278
|
+
|
|
279
|
+
if (frame.t === 'e') {
|
|
280
|
+
node.error(`Python error:\n${frame.v}`);
|
|
281
|
+
item.done(new Error(frame.v));
|
|
282
|
+
} else {
|
|
283
|
+
// Flush buffered content before signalling done
|
|
284
|
+
if (node.streamMode === 'delimited' && item.segmentBuffer) {
|
|
285
|
+
emitSegment(item, item.segmentBuffer);
|
|
286
|
+
item.segmentBuffer = '';
|
|
287
|
+
}
|
|
288
|
+
if (node.streamMode === 'raw' && item.rawBuffer) {
|
|
289
|
+
emitSegment(item, item.rawBuffer);
|
|
290
|
+
item.rawBuffer = '';
|
|
291
|
+
}
|
|
292
|
+
item.done();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
resetIdleTimer();
|
|
296
|
+
processNext();
|
|
297
|
+
computeAndStoreState();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── worker management ─────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
function createWorker() {
|
|
303
|
+
const proc = spawn(pythonPath, ['-u', '-c', PYTHON_BOOTSTRAP], {
|
|
304
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const worker = { proc, busy: false, currentItem: null, lineBuffer: '' };
|
|
308
|
+
|
|
309
|
+
proc.stdout.on('data', (data) => {
|
|
310
|
+
worker.lineBuffer += data.toString();
|
|
311
|
+
const lines = worker.lineBuffer.split('\n');
|
|
312
|
+
worker.lineBuffer = lines.pop();
|
|
313
|
+
for (const line of lines) {
|
|
314
|
+
if (line.trim()) handleStreamFrame(worker, line);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
proc.stderr.on('data', (data) => {
|
|
319
|
+
const text = Buffer.isBuffer(data) ? data.toString() : String(data);
|
|
320
|
+
if (text && text.trim().length > 0) node.warn(text.trim());
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
proc.on('error', (err) => {
|
|
324
|
+
if (closing) return;
|
|
325
|
+
const errMsg = (err && err.message) ? err.message : String(err);
|
|
326
|
+
node.error(`Python worker error: ${errMsg}`);
|
|
327
|
+
workers = workers.filter(w => w !== worker);
|
|
328
|
+
if (worker.currentItem) {
|
|
329
|
+
worker.currentItem.done(new Error(errMsg));
|
|
330
|
+
worker.currentItem = null;
|
|
331
|
+
}
|
|
332
|
+
worker.busy = false;
|
|
333
|
+
processNext();
|
|
334
|
+
computeAndStoreState();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
proc.on('close', (code) => {
|
|
338
|
+
if (closing) return;
|
|
339
|
+
workers = workers.filter(w => w !== worker);
|
|
340
|
+
if (worker.currentItem) {
|
|
341
|
+
node.warn(`Python worker exited (code ${code}) — failing current job`);
|
|
342
|
+
worker.currentItem.done(new Error(`Python worker exited with code ${code}`));
|
|
343
|
+
worker.currentItem = null;
|
|
344
|
+
}
|
|
345
|
+
worker.busy = false;
|
|
346
|
+
processNext();
|
|
347
|
+
computeAndStoreState();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return worker;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function ensureWorkers() {
|
|
354
|
+
while (workers.length < node.queue) {
|
|
355
|
+
workers.push(createWorker());
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function stopAllWorkers() {
|
|
360
|
+
for (const w of workers) {
|
|
361
|
+
try { w.proc.kill('SIGTERM'); } catch (_) {}
|
|
362
|
+
}
|
|
363
|
+
workers = [];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── idle timer ────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
function resetIdleTimer() {
|
|
369
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
370
|
+
idleTimer = setTimeout(() => {
|
|
371
|
+
idleTimer = null;
|
|
372
|
+
const anyBusy = workers.some(w => w.busy);
|
|
373
|
+
if (!anyBusy && pendingQueue.length === 0 && workers.length > 0) {
|
|
374
|
+
node.log('Idle timeout — stopping all Python workers');
|
|
375
|
+
stopAllWorkers();
|
|
376
|
+
computeAndStoreState();
|
|
377
|
+
}
|
|
378
|
+
}, IDLE_TIMEOUT_MS);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── scheduler ─────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
function processNext() {
|
|
384
|
+
if (pendingQueue.length === 0) return;
|
|
385
|
+
|
|
386
|
+
ensureWorkers();
|
|
387
|
+
|
|
388
|
+
const worker = workers.find(w => !w.busy);
|
|
389
|
+
if (!worker) return;
|
|
390
|
+
|
|
391
|
+
const item = pendingQueue.shift();
|
|
392
|
+
worker.busy = true;
|
|
393
|
+
worker.currentItem = item;
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
worker.proc.stdin.write(JSON.stringify({ code: item.rendered }) + '\n');
|
|
397
|
+
} catch (e) {
|
|
398
|
+
node.error(`Failed to write to Python stdin: ${e.message}`);
|
|
399
|
+
worker.busy = false;
|
|
400
|
+
worker.currentItem = null;
|
|
401
|
+
item.done(e);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
processNext(); // fill next free worker immediately
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── status ────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
function computeState() {
|
|
410
|
+
return {
|
|
411
|
+
waiting: pendingQueue.length,
|
|
412
|
+
executing: workers.filter(w => w.busy).length,
|
|
413
|
+
workers: workers.length
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function computeStatusFromState(state) {
|
|
418
|
+
return {
|
|
419
|
+
fill: state.workers > 0 ? 'blue' : 'grey',
|
|
420
|
+
shape: state.executing > 0 ? 'ring' : 'dot',
|
|
421
|
+
text: `${state.waiting} (${state.executing}/${node.queue})`
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function emitStatus() {
|
|
426
|
+
lastEmitTime = Date.now();
|
|
427
|
+
if (
|
|
428
|
+
lastEmittedState &&
|
|
429
|
+
currentState.waiting === lastEmittedState.waiting &&
|
|
430
|
+
currentState.executing === lastEmittedState.executing &&
|
|
431
|
+
currentState.workers === lastEmittedState.workers
|
|
432
|
+
) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
lastEmittedState = { ...currentState };
|
|
436
|
+
node.status(computeStatusFromState(currentState));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function scheduleStatusEmit() {
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
const idle = currentState.waiting === 0 && currentState.executing === 0;
|
|
442
|
+
if (idle) {
|
|
443
|
+
if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
|
|
444
|
+
emitStatus();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (now - lastEmitTime >= STATUS_INTERVAL_MS) {
|
|
448
|
+
emitStatus();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!statusTimer) {
|
|
452
|
+
const delay = STATUS_INTERVAL_MS - (now - lastEmitTime);
|
|
453
|
+
statusTimer = setTimeout(() => {
|
|
454
|
+
statusTimer = null;
|
|
455
|
+
emitStatus();
|
|
456
|
+
}, delay);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function computeAndStoreState() {
|
|
461
|
+
currentState = computeState();
|
|
462
|
+
node.currentState = currentState;
|
|
463
|
+
scheduleStatusEmit();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── killAll ───────────────────────────────────────────────────────────
|
|
467
|
+
// Used by: msg.stop, close handler, HTTP admin endpoint.
|
|
468
|
+
|
|
469
|
+
function killAll() {
|
|
470
|
+
for (const w of workers) {
|
|
471
|
+
if (w.currentItem) {
|
|
472
|
+
w.currentItem.done();
|
|
473
|
+
w.currentItem = null;
|
|
474
|
+
}
|
|
475
|
+
try { w.proc.kill('SIGTERM'); } catch (_) {}
|
|
476
|
+
}
|
|
477
|
+
workers = [];
|
|
478
|
+
for (const item of pendingQueue) {
|
|
479
|
+
item.done();
|
|
480
|
+
}
|
|
481
|
+
pendingQueue = [];
|
|
482
|
+
currentState = { waiting: 0, executing: 0, workers: 0 };
|
|
483
|
+
node.currentState = currentState;
|
|
484
|
+
node.status({ fill: 'grey', shape: 'dot', text: `0 (0/${node.queue})` });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
node.killAll = killAll;
|
|
488
|
+
|
|
489
|
+
// ── input handler ─────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
node.on('input', function(msg, send, done) {
|
|
492
|
+
if (msg.stop === true) {
|
|
493
|
+
killAll();
|
|
494
|
+
done();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const template = selectTemplate(msg, node);
|
|
499
|
+
const context = buildRawContext(msg, node);
|
|
500
|
+
|
|
501
|
+
let rendered;
|
|
502
|
+
try {
|
|
503
|
+
const env = new nunjucks.Environment(null, { autoescape: false });
|
|
504
|
+
rendered = env.renderString(template, context);
|
|
505
|
+
} catch (e) {
|
|
506
|
+
node.error('Template render error: ' + e.message + '\nTip: wrap variables in quotes — print("{{ payload }}")');
|
|
507
|
+
done(e);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
pendingQueue.push({ msg, send, done, rendered, segmentBuffer: '', rawBuffer: '' });
|
|
512
|
+
resetIdleTimer();
|
|
513
|
+
processNext();
|
|
514
|
+
computeAndStoreState();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ── close handler ─────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
node.on('close', function(done) {
|
|
520
|
+
closing = true;
|
|
521
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
522
|
+
if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
|
|
523
|
+
killAll();
|
|
524
|
+
done();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ── start ─────────────────────────────────────────────────────────────
|
|
528
|
+
// Lazy: workers start on first message, not at deploy time.
|
|
529
|
+
computeAndStoreState();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
RED.nodes.registerType('python.queue', PythonQueueNode);
|
|
533
|
+
|
|
534
|
+
RED.httpAdmin.post('/python-queue/:id/kill', function(req, res) {
|
|
535
|
+
const n = RED.nodes.getNode(req.params.id);
|
|
536
|
+
if (n && typeof n.killAll === 'function') {
|
|
537
|
+
n.killAll();
|
|
538
|
+
res.sendStatus(200);
|
|
539
|
+
} else {
|
|
540
|
+
res.sendStatus(404);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
RED.httpAdmin.get('/python-queue/:id/status', function(req, res) {
|
|
545
|
+
const n = RED.nodes.getNode(req.params.id);
|
|
546
|
+
if (n) {
|
|
547
|
+
res.json({
|
|
548
|
+
executing: n.currentState ? n.currentState.executing : 0,
|
|
549
|
+
workers: n.currentState ? n.currentState.workers : 0
|
|
550
|
+
});
|
|
551
|
+
} else {
|
|
552
|
+
res.sendStatus(404);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
};
|
package/utils/context.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const SKIP_KEYS = ['req', 'res', '_req', '_res'];
|
|
4
|
+
|
|
5
|
+
function safeStringify(obj) {
|
|
6
|
+
const seen = new WeakSet();
|
|
7
|
+
try {
|
|
8
|
+
return JSON.stringify(obj, (key, value) => {
|
|
9
|
+
if (typeof value === 'object' && value !== null) {
|
|
10
|
+
if (seen.has(value)) return '[Circular]';
|
|
11
|
+
seen.add(value);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
});
|
|
15
|
+
} catch (e) {
|
|
16
|
+
return '[Unserializable Object]';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Builds the Nunjucks rendering context from msg and node.
|
|
21
|
+
// All msg values are coerced to strings so that {{ payload }} is always safe
|
|
22
|
+
// to embed anywhere in the template without quotes or filters:
|
|
23
|
+
// string → kept as-is
|
|
24
|
+
// number → String(v)
|
|
25
|
+
// object → safeStringify(v) (circular-safe, capped at 10 000 chars)
|
|
26
|
+
// null/undefined → ""
|
|
27
|
+
// Keys in SKIP_KEYS (req, res, etc.) are omitted to avoid serializing
|
|
28
|
+
// non-serializable Node-RED runtime objects.
|
|
29
|
+
function buildContext(msg, node) {
|
|
30
|
+
const ctx = {};
|
|
31
|
+
|
|
32
|
+
for (const k of Object.keys(msg || {})) {
|
|
33
|
+
if (SKIP_KEYS.includes(k)) continue;
|
|
34
|
+
|
|
35
|
+
const v = msg[k];
|
|
36
|
+
|
|
37
|
+
if (v === null || v === undefined) {
|
|
38
|
+
ctx[k] = '';
|
|
39
|
+
} else if (typeof v === 'object') {
|
|
40
|
+
const str = safeStringify(v);
|
|
41
|
+
ctx[k] = str.length > 10000 ? '[Object too large]' : str;
|
|
42
|
+
} else {
|
|
43
|
+
ctx[k] = String(v);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ctx.flow = { get: (key) => node.context().flow.get(key) };
|
|
48
|
+
ctx.global = { get: (key) => node.context().global.get(key) };
|
|
49
|
+
ctx.env = process.env;
|
|
50
|
+
|
|
51
|
+
return ctx;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { buildContext };
|