@pauldeng/node-red-contrib-bullmq 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 +22 -0
- package/README.md +124 -0
- package/bull-queue.html +530 -0
- package/bull-queue.js +523 -0
- package/docs/ARCHITECTURE.md +52 -0
- package/docs/CHANGE_WORKFLOW.md +27 -0
- package/docs/COMMANDS.md +89 -0
- package/docs/CONNECTIONS.md +54 -0
- package/docs/MIGRATION.md +37 -0
- package/docs/NODE_GUIDE.md +83 -0
- package/docs/REFERENCE_MAP.md +51 -0
- package/docs/RELEASE.md +108 -0
- package/docs/TESTING.md +88 -0
- package/docs/TROUBLESHOOTING.md +28 -0
- package/examples/README.md +35 -0
- package/examples/bullmq_features.json +331 -0
- package/examples/example_flow.json +277 -0
- package/examples/repeatable_jobs.json +245 -0
- package/icons/bull_icon.png +0 -0
- package/lib/acknowledgements.js +110 -0
- package/lib/commands.js +230 -0
- package/lib/connections.js +300 -0
- package/lib/scheduler.js +114 -0
- package/lib/serialization.js +63 -0
- package/package.json +70 -0
package/bull-queue.js
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
FlowProducer,
|
|
5
|
+
Queue,
|
|
6
|
+
QueueEvents,
|
|
7
|
+
UnrecoverableError,
|
|
8
|
+
Worker,
|
|
9
|
+
} = require("bullmq");
|
|
10
|
+
const IORedis = require("ioredis");
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
AcknowledgementRegistry,
|
|
14
|
+
parseAckTimeoutMs,
|
|
15
|
+
} = require("./lib/acknowledgements");
|
|
16
|
+
const { dispatchCommand } = require("./lib/commands");
|
|
17
|
+
const {
|
|
18
|
+
buildBullMQOptions,
|
|
19
|
+
buildRedisDescriptor,
|
|
20
|
+
createRedisConnection,
|
|
21
|
+
normalizeQueueConfig,
|
|
22
|
+
} = require("./lib/connections");
|
|
23
|
+
const { serializeFlowJob, serializeJob } = require("./lib/serialization");
|
|
24
|
+
|
|
25
|
+
const DEFAULT_EVENTS = [
|
|
26
|
+
"active",
|
|
27
|
+
"added",
|
|
28
|
+
"cleaned",
|
|
29
|
+
"completed",
|
|
30
|
+
"deduplicated",
|
|
31
|
+
"delayed",
|
|
32
|
+
"drained",
|
|
33
|
+
"duplicated",
|
|
34
|
+
"failed",
|
|
35
|
+
"paused",
|
|
36
|
+
"progress",
|
|
37
|
+
"removed",
|
|
38
|
+
"resumed",
|
|
39
|
+
"stalled",
|
|
40
|
+
"waiting",
|
|
41
|
+
"waiting-children",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
async function closeResource(resource) {
|
|
45
|
+
if (!resource) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (typeof resource.close === "function") {
|
|
49
|
+
await resource.close();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (typeof resource.quit === "function") {
|
|
53
|
+
await resource.quit();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (typeof resource.disconnect === "function") {
|
|
57
|
+
resource.disconnect();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function nodeSend(node, send, msg) {
|
|
62
|
+
(send || node.send).call(node, msg);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function nodeDone(node, done, err, msg) {
|
|
66
|
+
if (done) {
|
|
67
|
+
done(err);
|
|
68
|
+
} else if (err) {
|
|
69
|
+
node.error(err, msg);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parsePositiveInteger(value, defaultValue) {
|
|
74
|
+
if (value === undefined || value === null || value === "") {
|
|
75
|
+
return defaultValue;
|
|
76
|
+
}
|
|
77
|
+
const parsed = Number(value);
|
|
78
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
79
|
+
throw new Error(`Expected a positive integer, got ${value}`);
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseEventFilter(value) {
|
|
85
|
+
if (!value) {
|
|
86
|
+
return DEFAULT_EVENTS;
|
|
87
|
+
}
|
|
88
|
+
return String(value)
|
|
89
|
+
.split(/[\n,]+/)
|
|
90
|
+
.map((event) => event.trim())
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function attachErrorListener(resource, node, label) {
|
|
95
|
+
if (!resource || typeof resource.on !== "function") {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
resource.on("error", (err) => {
|
|
99
|
+
node.status({ fill: "red", shape: "ring", text: `${label}: error` });
|
|
100
|
+
node.error(err);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createJobMessage(job, queueName, extraBull = {}) {
|
|
105
|
+
const data = job.data || {};
|
|
106
|
+
return {
|
|
107
|
+
payload:
|
|
108
|
+
data && Object.prototype.hasOwnProperty.call(data, "payload")
|
|
109
|
+
? data.payload
|
|
110
|
+
: data,
|
|
111
|
+
job: serializeJob(job),
|
|
112
|
+
bull: {
|
|
113
|
+
queue: queueName,
|
|
114
|
+
jobId: job.id,
|
|
115
|
+
...extraBull,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = function registerBullMQNodes(RED) {
|
|
121
|
+
const acknowledgements = new AcknowledgementRegistry();
|
|
122
|
+
|
|
123
|
+
function BullQueueServerSetup(n) {
|
|
124
|
+
RED.nodes.createNode(this, n);
|
|
125
|
+
const node = this;
|
|
126
|
+
|
|
127
|
+
node.users = {};
|
|
128
|
+
node.resources = new Set();
|
|
129
|
+
node.config = normalizeQueueConfig(n, node.credentials || {});
|
|
130
|
+
node.queue = null;
|
|
131
|
+
node.producerConnection = null;
|
|
132
|
+
|
|
133
|
+
node.register = function register(bullNode) {
|
|
134
|
+
node.users[bullNode.id] = bullNode;
|
|
135
|
+
bullNode.status({
|
|
136
|
+
fill: "grey",
|
|
137
|
+
shape: "ring",
|
|
138
|
+
text: "configured",
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
node.deregister = function deregister(bullNode, done) {
|
|
143
|
+
delete node.users[bullNode.id];
|
|
144
|
+
done();
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// owner is the node whose status should reflect connection errors. It
|
|
148
|
+
// defaults to the config node (shared producer/queue), but runtime nodes
|
|
149
|
+
// pass themselves so errors surface on their own visible status.
|
|
150
|
+
node.createConnection = function createConnection(role, owner = node) {
|
|
151
|
+
const descriptor = buildRedisDescriptor(node.config, role);
|
|
152
|
+
const connection = createRedisConnection(descriptor, IORedis);
|
|
153
|
+
attachErrorListener(connection, owner, `Redis ${role}`);
|
|
154
|
+
node.resources.add(connection);
|
|
155
|
+
return connection;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
node.getQueue = function getQueue() {
|
|
159
|
+
if (!node.queue) {
|
|
160
|
+
node.producerConnection = node.createConnection("producer");
|
|
161
|
+
node.queue = new Queue(
|
|
162
|
+
node.config.queueName,
|
|
163
|
+
buildBullMQOptions(node.config, node.producerConnection)
|
|
164
|
+
);
|
|
165
|
+
attachErrorListener(node.queue, node, "BullMQ queue");
|
|
166
|
+
}
|
|
167
|
+
return node.queue;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Runtime nodes pass themselves as owner and attach their own resource
|
|
171
|
+
// error listener, so worker/events/flow errors surface on the visible
|
|
172
|
+
// runtime node rather than the hidden config node.
|
|
173
|
+
node.createWorker = function createWorker(processor, options, owner = node) {
|
|
174
|
+
const connection = node.createConnection("worker", owner);
|
|
175
|
+
const worker = new Worker(node.config.queueName, processor, {
|
|
176
|
+
...buildBullMQOptions(node.config, connection),
|
|
177
|
+
...options,
|
|
178
|
+
});
|
|
179
|
+
node.resources.add(worker);
|
|
180
|
+
return worker;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
node.createQueueEvents = function createQueueEvents(owner = node) {
|
|
184
|
+
const connection = node.createConnection("events", owner);
|
|
185
|
+
const queueEvents = new QueueEvents(
|
|
186
|
+
node.config.queueName,
|
|
187
|
+
buildBullMQOptions(node.config, connection)
|
|
188
|
+
);
|
|
189
|
+
node.resources.add(queueEvents);
|
|
190
|
+
return queueEvents;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
node.createFlowProducer = function createFlowProducer(owner = node) {
|
|
194
|
+
const connection = node.createConnection("producer", owner);
|
|
195
|
+
const flowProducer = new FlowProducer(
|
|
196
|
+
buildBullMQOptions(node.config, connection)
|
|
197
|
+
);
|
|
198
|
+
node.resources.add(flowProducer);
|
|
199
|
+
return flowProducer;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
node.on("close", async function onClose(removed, done) {
|
|
203
|
+
try {
|
|
204
|
+
if (node.queue) {
|
|
205
|
+
await closeResource(node.queue);
|
|
206
|
+
}
|
|
207
|
+
const resources = Array.from(node.resources).reverse();
|
|
208
|
+
for (const resource of resources) {
|
|
209
|
+
await closeResource(resource);
|
|
210
|
+
}
|
|
211
|
+
node.status({});
|
|
212
|
+
done();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
done(err);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
RED.nodes.registerType("bull-queue-server", BullQueueServerSetup, {
|
|
220
|
+
credentials: {
|
|
221
|
+
password: { type: "password" },
|
|
222
|
+
sentinelPassword: { type: "password" },
|
|
223
|
+
tlsCa: { type: "password" },
|
|
224
|
+
tlsCert: { type: "password" },
|
|
225
|
+
tlsKey: { type: "password" },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
function BullQueueCmdNode(n) {
|
|
230
|
+
RED.nodes.createNode(this, n);
|
|
231
|
+
const node = this;
|
|
232
|
+
node.queue = n.queue;
|
|
233
|
+
node.bullConn = RED.nodes.getNode(node.queue);
|
|
234
|
+
|
|
235
|
+
if (!node.bullConn) {
|
|
236
|
+
node.status({ fill: "red", shape: "ring", text: "missing queue" });
|
|
237
|
+
node.error("Missing bull-queue-server config node");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
node.bullConn.register(node);
|
|
242
|
+
node.status({ fill: "green", shape: "dot", text: "configured" });
|
|
243
|
+
|
|
244
|
+
node.on("input", async function onInput(msg, send, done) {
|
|
245
|
+
try {
|
|
246
|
+
const result = await dispatchCommand(node.bullConn.getQueue(), msg);
|
|
247
|
+
msg.payload = result;
|
|
248
|
+
nodeSend(node, send, msg);
|
|
249
|
+
nodeDone(node, done);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
nodeDone(node, done, err, msg);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
node.on("close", function onClose(removed, done) {
|
|
256
|
+
node.bullConn.deregister(node, done);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function BullQueueRunNode(n) {
|
|
261
|
+
RED.nodes.createNode(this, n);
|
|
262
|
+
const node = this;
|
|
263
|
+
node.queue = n.queue;
|
|
264
|
+
node.bullQueue = RED.nodes.getNode(node.queue);
|
|
265
|
+
node.completionMode = n.completionMode || "immediate";
|
|
266
|
+
|
|
267
|
+
if (!node.bullQueue) {
|
|
268
|
+
node.status({ fill: "red", shape: "ring", text: "missing queue" });
|
|
269
|
+
node.error("Missing bull-queue-server config node");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
node.bullQueue.register(node);
|
|
274
|
+
|
|
275
|
+
const workerOptions = {
|
|
276
|
+
concurrency: parsePositiveInteger(n.concurrency, 1),
|
|
277
|
+
};
|
|
278
|
+
if (n.limiterMax && n.limiterDuration) {
|
|
279
|
+
workerOptions.limiter = {
|
|
280
|
+
max: parsePositiveInteger(n.limiterMax),
|
|
281
|
+
duration: parsePositiveInteger(n.limiterDuration),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const processor = async (job) => {
|
|
286
|
+
if (node.completionMode === "manual") {
|
|
287
|
+
const timeoutMs = parseAckTimeoutMs(n.ackTimeout);
|
|
288
|
+
const acknowledgement = acknowledgements.create(
|
|
289
|
+
{
|
|
290
|
+
job,
|
|
291
|
+
queue: node.bullQueue.getQueue(),
|
|
292
|
+
queueName: node.bullQueue.config.queueName,
|
|
293
|
+
runNodeId: node.id,
|
|
294
|
+
},
|
|
295
|
+
timeoutMs
|
|
296
|
+
);
|
|
297
|
+
node.send(
|
|
298
|
+
createJobMessage(job, node.bullQueue.config.queueName, {
|
|
299
|
+
ackId: acknowledgement.ackId,
|
|
300
|
+
runNodeId: node.id,
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
return await acknowledgement.entry.wait();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const msg = createJobMessage(job, node.bullQueue.config.queueName);
|
|
307
|
+
node.send(msg);
|
|
308
|
+
return msg.payload;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
node.worker = node.bullQueue.createWorker(processor, workerOptions, node);
|
|
312
|
+
attachErrorListener(node.worker, node, "BullMQ worker");
|
|
313
|
+
node.worker.on("ready", () =>
|
|
314
|
+
node.status({ fill: "green", shape: "dot", text: "connected" })
|
|
315
|
+
);
|
|
316
|
+
node.worker.on("closed", () =>
|
|
317
|
+
node.status({ fill: "red", shape: "ring", text: "closed" })
|
|
318
|
+
);
|
|
319
|
+
node.status({ fill: "yellow", shape: "ring", text: "connecting" });
|
|
320
|
+
|
|
321
|
+
node.on("close", async function onClose(removed, done) {
|
|
322
|
+
acknowledgements.rejectByRunNode(
|
|
323
|
+
node.id,
|
|
324
|
+
new Error("BullMQ run node closed before acknowledgement")
|
|
325
|
+
);
|
|
326
|
+
try {
|
|
327
|
+
await closeResource(node.worker);
|
|
328
|
+
node.bullQueue.deregister(node, () => {});
|
|
329
|
+
done();
|
|
330
|
+
} catch (err) {
|
|
331
|
+
done(err);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function BullJobNode(n) {
|
|
337
|
+
RED.nodes.createNode(this, n);
|
|
338
|
+
const node = this;
|
|
339
|
+
node.action = n.action || "complete";
|
|
340
|
+
|
|
341
|
+
node.on("input", async function onInput(msg, send, done) {
|
|
342
|
+
try {
|
|
343
|
+
const ackId = msg.bull && msg.bull.ackId;
|
|
344
|
+
const context = acknowledgements.get(ackId);
|
|
345
|
+
const action = msg.cmd || node.action;
|
|
346
|
+
|
|
347
|
+
switch (action) {
|
|
348
|
+
case "progress":
|
|
349
|
+
await context.job.updateProgress(
|
|
350
|
+
msg.progress !== undefined ? msg.progress : msg.payload
|
|
351
|
+
);
|
|
352
|
+
msg.payload = serializeJob(context.job);
|
|
353
|
+
nodeSend(node, send, msg);
|
|
354
|
+
nodeDone(node, done);
|
|
355
|
+
return;
|
|
356
|
+
case "removeDeduplicationKey":
|
|
357
|
+
msg.payload = await context.job.removeDeduplicationKey();
|
|
358
|
+
nodeSend(node, send, msg);
|
|
359
|
+
nodeDone(node, done);
|
|
360
|
+
return;
|
|
361
|
+
case "getChildrenValues":
|
|
362
|
+
msg.payload = await context.job.getChildrenValues();
|
|
363
|
+
nodeSend(node, send, msg);
|
|
364
|
+
nodeDone(node, done);
|
|
365
|
+
return;
|
|
366
|
+
case "getFailedChildrenValues":
|
|
367
|
+
msg.payload = await context.job.getFailedChildrenValues();
|
|
368
|
+
nodeSend(node, send, msg);
|
|
369
|
+
nodeDone(node, done);
|
|
370
|
+
return;
|
|
371
|
+
case "removeUnprocessedChildren":
|
|
372
|
+
msg.payload = await context.job.removeUnprocessedChildren();
|
|
373
|
+
nodeSend(node, send, msg);
|
|
374
|
+
nodeDone(node, done);
|
|
375
|
+
return;
|
|
376
|
+
case "complete": {
|
|
377
|
+
const result = msg.result !== undefined ? msg.result : msg.payload;
|
|
378
|
+
context.complete(result);
|
|
379
|
+
msg.payload = result;
|
|
380
|
+
nodeSend(node, send, msg);
|
|
381
|
+
nodeDone(node, done);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
case "fail": {
|
|
385
|
+
const error =
|
|
386
|
+
msg.error instanceof Error
|
|
387
|
+
? msg.error
|
|
388
|
+
: new Error(String(msg.error || msg.payload));
|
|
389
|
+
context.fail(error);
|
|
390
|
+
nodeDone(node, done);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
case "failUnrecoverable": {
|
|
394
|
+
const errorText = String(
|
|
395
|
+
msg.error || msg.payload || "Unrecoverable BullMQ job failure"
|
|
396
|
+
);
|
|
397
|
+
context.fail(new UnrecoverableError(errorText));
|
|
398
|
+
nodeDone(node, done);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
case "rateLimit":
|
|
402
|
+
await context.queue.rateLimit(msg.duration);
|
|
403
|
+
context.fail(Worker.RateLimitError());
|
|
404
|
+
nodeDone(node, done);
|
|
405
|
+
return;
|
|
406
|
+
default:
|
|
407
|
+
throw new Error(`Unsupported bull job action: ${action}`);
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
nodeDone(node, done, err, msg);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function BullEventsNode(n) {
|
|
416
|
+
RED.nodes.createNode(this, n);
|
|
417
|
+
const node = this;
|
|
418
|
+
node.queue = n.queue;
|
|
419
|
+
node.bullConn = RED.nodes.getNode(node.queue);
|
|
420
|
+
|
|
421
|
+
if (!node.bullConn) {
|
|
422
|
+
node.status({ fill: "red", shape: "ring", text: "missing queue" });
|
|
423
|
+
node.error("Missing bull-queue-server config node");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
node.bullConn.register(node);
|
|
428
|
+
node.queueEvents = node.bullConn.createQueueEvents(node);
|
|
429
|
+
attachErrorListener(node.queueEvents, node, "BullMQ events");
|
|
430
|
+
const events = parseEventFilter(n.events);
|
|
431
|
+
for (const event of events) {
|
|
432
|
+
node.queueEvents.on(event, (payload, eventId) => {
|
|
433
|
+
node.send({
|
|
434
|
+
topic: event,
|
|
435
|
+
payload,
|
|
436
|
+
bull: {
|
|
437
|
+
queue: node.bullConn.config.queueName,
|
|
438
|
+
event,
|
|
439
|
+
eventId,
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
async function updateReadyStatus() {
|
|
445
|
+
try {
|
|
446
|
+
await node.queueEvents.waitUntilReady();
|
|
447
|
+
node.status({ fill: "green", shape: "dot", text: "connected" });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
node.error(err);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
updateReadyStatus();
|
|
453
|
+
|
|
454
|
+
node.on("close", async function onClose(removed, done) {
|
|
455
|
+
try {
|
|
456
|
+
await closeResource(node.queueEvents);
|
|
457
|
+
node.bullConn.deregister(node, () => {});
|
|
458
|
+
done();
|
|
459
|
+
} catch (err) {
|
|
460
|
+
done(err);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function BullFlowNode(n) {
|
|
466
|
+
RED.nodes.createNode(this, n);
|
|
467
|
+
const node = this;
|
|
468
|
+
node.queue = n.queue;
|
|
469
|
+
node.bullConn = RED.nodes.getNode(node.queue);
|
|
470
|
+
|
|
471
|
+
if (!node.bullConn) {
|
|
472
|
+
node.status({ fill: "red", shape: "ring", text: "missing queue" });
|
|
473
|
+
node.error("Missing bull-queue-server config node");
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
node.bullConn.register(node);
|
|
478
|
+
node.flowProducer = node.bullConn.createFlowProducer(node);
|
|
479
|
+
attachErrorListener(node.flowProducer, node, "BullMQ flow");
|
|
480
|
+
async function updateReadyStatus() {
|
|
481
|
+
try {
|
|
482
|
+
node.status({ fill: "yellow", shape: "ring", text: "connecting" });
|
|
483
|
+
await node.flowProducer.waitUntilReady();
|
|
484
|
+
node.status({ fill: "green", shape: "dot", text: "connected" });
|
|
485
|
+
} catch (err) {
|
|
486
|
+
node.status({ fill: "red", shape: "ring", text: "connection error" });
|
|
487
|
+
node.error(err);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
updateReadyStatus();
|
|
491
|
+
|
|
492
|
+
node.on("input", async function onInput(msg, send, done) {
|
|
493
|
+
try {
|
|
494
|
+
if (!msg.payload || typeof msg.payload !== "object") {
|
|
495
|
+
throw new Error("bull flow requires msg.payload to contain a flow tree");
|
|
496
|
+
}
|
|
497
|
+
msg.payload = serializeFlowJob(
|
|
498
|
+
await node.flowProducer.add(msg.payload, msg.flowopts)
|
|
499
|
+
);
|
|
500
|
+
nodeSend(node, send, msg);
|
|
501
|
+
nodeDone(node, done);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
nodeDone(node, done, err, msg);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
node.on("close", async function onClose(removed, done) {
|
|
508
|
+
try {
|
|
509
|
+
await closeResource(node.flowProducer);
|
|
510
|
+
node.bullConn.deregister(node, () => {});
|
|
511
|
+
done();
|
|
512
|
+
} catch (err) {
|
|
513
|
+
done(err);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
RED.nodes.registerType("bull cmd", BullQueueCmdNode);
|
|
519
|
+
RED.nodes.registerType("bull run", BullQueueRunNode);
|
|
520
|
+
RED.nodes.registerType("bull job", BullJobNode);
|
|
521
|
+
RED.nodes.registerType("bull events", BullEventsNode);
|
|
522
|
+
RED.nodes.registerType("bull flow", BullFlowNode);
|
|
523
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
The package remains a single Node-RED module entry point, but BullMQ behavior is split into focused CommonJS helpers.
|
|
4
|
+
|
|
5
|
+
## Entry Point
|
|
6
|
+
|
|
7
|
+
`bull-queue.js` registers:
|
|
8
|
+
|
|
9
|
+
- `bull-queue-server`
|
|
10
|
+
- `bull cmd`
|
|
11
|
+
- `bull run`
|
|
12
|
+
- `bull job`
|
|
13
|
+
- `bull events`
|
|
14
|
+
- `bull flow`
|
|
15
|
+
|
|
16
|
+
The runtime does Node-RED lifecycle work only: creating nodes, wiring input handlers, setting status, and closing resources.
|
|
17
|
+
|
|
18
|
+
## Connections
|
|
19
|
+
|
|
20
|
+
`bull-queue-server` owns queue name and Redis deployment config. It creates role-specific ioredis connections:
|
|
21
|
+
|
|
22
|
+
- producer connections fail quickly with bounded retries;
|
|
23
|
+
- worker and event connections use `maxRetriesPerRequest: null`;
|
|
24
|
+
- QueueEvents uses a dedicated connection;
|
|
25
|
+
- Cluster and MemoryDB use `{bull}` by default as the BullMQ prefix.
|
|
26
|
+
|
|
27
|
+
Connection and resource errors are reported on the consuming runtime node's status (`bull run`, `bull events`, `bull flow`). The shared producer connection and queue used by `bull cmd` report on the config node.
|
|
28
|
+
|
|
29
|
+
Secrets are read from Node-RED credentials first, with legacy plain fields accepted only for backward compatibility.
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
`lib/commands.js` maps `msg.cmd` to explicit BullMQ calls. It does not expose arbitrary method names. Legacy repeat commands call Job Scheduler APIs.
|
|
34
|
+
|
|
35
|
+
## Schedulers
|
|
36
|
+
|
|
37
|
+
`lib/scheduler.js` translates `msg.jobopts.repeat.cron` to `repeat.pattern` and requires a deterministic scheduler id. Scheduler lookup/removal uses exact ids.
|
|
38
|
+
|
|
39
|
+
## Workers And Acknowledgement
|
|
40
|
+
|
|
41
|
+
`bull run` creates a BullMQ Worker.
|
|
42
|
+
|
|
43
|
+
- Immediate mode sends a Node-RED message and completes the job immediately.
|
|
44
|
+
- Manual mode creates an opaque `msg.bull.ackId` and waits for a downstream `bull job` node.
|
|
45
|
+
|
|
46
|
+
The in-process acknowledgement registry (`lib/acknowledgements.js`) stores live jobs and promise settlement functions. Each entry self-removes when it settles (complete, fail, timeout, or run-node close), so the registry does not accumulate finished jobs. Lock tokens are never sent in messages.
|
|
47
|
+
|
|
48
|
+
## Events And Flows
|
|
49
|
+
|
|
50
|
+
`bull events` wraps QueueEvents and emits event messages with `msg.topic`, `msg.payload`, and `msg.bull` metadata.
|
|
51
|
+
|
|
52
|
+
`bull flow` wraps FlowProducer and serializes the returned parent/child tree.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Change Workflow
|
|
2
|
+
|
|
3
|
+
## Behavior Changes
|
|
4
|
+
|
|
5
|
+
1. Read `GOAL.md` if present and the relevant docs in `docs/REFERENCE_MAP.md`.
|
|
6
|
+
2. Add or update the smallest failing test.
|
|
7
|
+
3. Run the focused test and confirm the expected failure.
|
|
8
|
+
4. Implement the minimal change.
|
|
9
|
+
5. Run the focused test and then `npm test`.
|
|
10
|
+
6. Update README, node help, and docs when public behavior changes.
|
|
11
|
+
7. Record unsupported BullMQ behavior with a reason instead of silently omitting it.
|
|
12
|
+
|
|
13
|
+
## Connection Changes
|
|
14
|
+
|
|
15
|
+
Update `docs/CONNECTIONS.md` and tests in `test/connections.test.js`. Do not pass arbitrary ioredis options through messages or editor fields.
|
|
16
|
+
|
|
17
|
+
## Scheduler Changes
|
|
18
|
+
|
|
19
|
+
Update `test/scheduler.test.js`. Legacy repeat behavior must use exact scheduler ids and must not call deprecated repeatable-job APIs.
|
|
20
|
+
|
|
21
|
+
## Editor Changes
|
|
22
|
+
|
|
23
|
+
Update `bull-queue.html`, static editor contract tests, and Playwright coverage. Credential fields must not export secrets in flow JSON.
|
|
24
|
+
|
|
25
|
+
## Security
|
|
26
|
+
|
|
27
|
+
Never commit MemoryDB credentials, Redis passwords, private keys, or generated TLS private keys.
|
package/docs/COMMANDS.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Command Reference
|
|
2
|
+
|
|
3
|
+
`bull cmd` reads `msg.cmd`. It writes the command result to `msg.payload`.
|
|
4
|
+
|
|
5
|
+
## Jobs
|
|
6
|
+
|
|
7
|
+
- `add`
|
|
8
|
+
- `addBulk`
|
|
9
|
+
- `getJob`
|
|
10
|
+
- `getJobs`
|
|
11
|
+
- `getJobState`
|
|
12
|
+
- `removeJob`
|
|
13
|
+
- `retryJob`
|
|
14
|
+
- `retryJobs`
|
|
15
|
+
|
|
16
|
+
`add` uses `msg.jobName`, `msg.jobData` or `msg.payload`, and `msg.jobopts`.
|
|
17
|
+
|
|
18
|
+
## Delayed Jobs
|
|
19
|
+
|
|
20
|
+
- `getDelayed`
|
|
21
|
+
- `changeDelay`
|
|
22
|
+
- `promoteJob`
|
|
23
|
+
- `promoteJobs`
|
|
24
|
+
|
|
25
|
+
Add a delayed job with `msg.jobopts.delay`.
|
|
26
|
+
|
|
27
|
+
## Priorities
|
|
28
|
+
|
|
29
|
+
- `getPrioritized`
|
|
30
|
+
- `getCountsPerPriority`
|
|
31
|
+
- `changePriority`
|
|
32
|
+
|
|
33
|
+
Add a prioritized job with `msg.jobopts.priority`.
|
|
34
|
+
|
|
35
|
+
## Deduplication
|
|
36
|
+
|
|
37
|
+
- `getDeduplicationJobId`
|
|
38
|
+
- `removeDeduplicationKey`
|
|
39
|
+
|
|
40
|
+
Add deduplication options through `msg.jobopts.deduplication`.
|
|
41
|
+
|
|
42
|
+
## Job Schedulers
|
|
43
|
+
|
|
44
|
+
Native:
|
|
45
|
+
|
|
46
|
+
- `upsertJobScheduler`
|
|
47
|
+
- `getJobScheduler`
|
|
48
|
+
- `getJobSchedulers`
|
|
49
|
+
- `getJobSchedulersCount`
|
|
50
|
+
- `removeJobScheduler`
|
|
51
|
+
|
|
52
|
+
Legacy aliases:
|
|
53
|
+
|
|
54
|
+
- `add` with `msg.jobopts.repeat`
|
|
55
|
+
- `count`
|
|
56
|
+
- `getRepeatableJobs`
|
|
57
|
+
- `getRepeatableJobByKey`
|
|
58
|
+
- `removeRepeatableByKey`
|
|
59
|
+
|
|
60
|
+
Legacy lookup/removal is exact-id based.
|
|
61
|
+
|
|
62
|
+
## Queue Administration
|
|
63
|
+
|
|
64
|
+
- `getJobCounts`
|
|
65
|
+
- `pause`
|
|
66
|
+
- `resume`
|
|
67
|
+
- `drain`
|
|
68
|
+
- `clean`
|
|
69
|
+
- `stopAndRemoveAllJobs`
|
|
70
|
+
|
|
71
|
+
`stopAndRemoveAllJobs` removes schedulers, drains waiting/delayed jobs, and cleans inactive states. It does not claim to safely remove active jobs.
|
|
72
|
+
|
|
73
|
+
## Concurrency And Rate Limits
|
|
74
|
+
|
|
75
|
+
- `setGlobalConcurrency`
|
|
76
|
+
- `getGlobalConcurrency`
|
|
77
|
+
- `removeGlobalConcurrency`
|
|
78
|
+
- `setGlobalRateLimit`
|
|
79
|
+
- `getGlobalRateLimit`
|
|
80
|
+
- `removeGlobalRateLimit`
|
|
81
|
+
- `rateLimit`
|
|
82
|
+
- `getRateLimitTtl`
|
|
83
|
+
- `removeRateLimitKey`
|
|
84
|
+
|
|
85
|
+
## Logs And Metrics
|
|
86
|
+
|
|
87
|
+
- `addJobLog`
|
|
88
|
+
- `getJobLogs`
|
|
89
|
+
- `exportPrometheusMetrics`
|