@pauldeng/node-red-contrib-bullmq 1.0.2 → 2.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/README.md +62 -42
- package/bull-queue.html +300 -91
- package/bull-queue.js +686 -143
- package/docs/ARCHITECTURE.md +63 -17
- package/docs/CHANGE_WORKFLOW.md +10 -11
- package/docs/COMMANDS.md +72 -11
- package/docs/CONNECTIONS.md +74 -7
- package/docs/MIGRATION.md +56 -22
- package/docs/NODE_GUIDE.md +52 -14
- package/docs/REFERENCE_MAP.md +29 -10
- package/docs/RELEASE.md +18 -5
- package/docs/RULES.md +33 -0
- package/docs/TELEMETRY.md +55 -0
- package/docs/TESTING.md +30 -16
- package/docs/TROUBLESHOOTING.md +44 -5
- package/examples/README.md +40 -12
- package/examples/bullmq_features.json +218 -16
- package/examples/example_flow.json +11 -11
- package/examples/postgres_backend.json +149 -0
- package/examples/repeatable_jobs.json +51 -19
- package/examples/scheduled_notifications.json +216 -0
- package/lib/acknowledgements.js +19 -1
- package/lib/commands.js +74 -35
- package/lib/connections.js +271 -48
- package/lib/scheduler.js +5 -74
- package/lib/serialization.js +1 -4
- package/package.json +25 -9
package/bull-queue.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
|
+
createPostgresBackend,
|
|
5
|
+
DelayedError,
|
|
4
6
|
FlowProducer,
|
|
7
|
+
MINIMUM_POSTGRES_VERSION,
|
|
5
8
|
Queue,
|
|
6
9
|
QueueEvents,
|
|
10
|
+
RECOMMENDED_POSTGRES_VERSION,
|
|
11
|
+
SchemaMigrationRequiredError,
|
|
12
|
+
SchemaVersionMismatchError,
|
|
7
13
|
UnrecoverableError,
|
|
14
|
+
UnsupportedPostgresVersionError,
|
|
15
|
+
WaitingError,
|
|
8
16
|
Worker,
|
|
9
17
|
} = require("bullmq");
|
|
10
18
|
const IORedis = require("ioredis");
|
|
@@ -16,12 +24,14 @@ const {
|
|
|
16
24
|
} = require("./lib/acknowledgements");
|
|
17
25
|
const { dispatchCommand } = require("./lib/commands");
|
|
18
26
|
const {
|
|
27
|
+
POSTGRES_CONNECTION_TIMEOUT_MS,
|
|
19
28
|
buildBullMQOptions,
|
|
20
29
|
buildRedisDescriptor,
|
|
21
30
|
createRedisConnection,
|
|
22
31
|
normalizeQueueConfig,
|
|
23
32
|
} = require("./lib/connections");
|
|
24
33
|
const { serializeFlowJob, serializeJob } = require("./lib/serialization");
|
|
34
|
+
const { version: PACKAGE_VERSION } = require("./package.json");
|
|
25
35
|
|
|
26
36
|
const DEFAULT_EVENTS = [
|
|
27
37
|
"active",
|
|
@@ -37,6 +47,7 @@ const DEFAULT_EVENTS = [
|
|
|
37
47
|
"progress",
|
|
38
48
|
"removed",
|
|
39
49
|
"resumed",
|
|
50
|
+
"retries-exhausted",
|
|
40
51
|
"stalled",
|
|
41
52
|
"waiting",
|
|
42
53
|
"waiting-children",
|
|
@@ -47,22 +58,53 @@ const DEFAULT_EVENTS = [
|
|
|
47
58
|
// an unreachable Redis server.
|
|
48
59
|
const CLOSE_GRACE_MS = 1000;
|
|
49
60
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
// A connection that is actually ready gets a longer budget: Worker.close()
|
|
62
|
+
// waits for in-flight jobs, and cutting that off abandons a running job to the
|
|
63
|
+
// stalled checker, which re-runs it and can eventually fail it for exceeding
|
|
64
|
+
// maxStalledCount. The ceiling stays well under Node-RED's own ~15s node close
|
|
65
|
+
// timeout, and resources close concurrently, so this bounds one resource, not
|
|
66
|
+
// the sum.
|
|
67
|
+
const GRACEFUL_CLOSE_MS = 10000;
|
|
68
|
+
|
|
69
|
+
// PostgreSQL's connection timeout is its only way to stop an outstanding
|
|
70
|
+
// connect. Unlike Redis, it has no raw client that we can force closed, so its
|
|
71
|
+
// close budget needs scheduling margin beyond that timeout. Derived from the
|
|
72
|
+
// timeout it has to outlast rather than written as a literal, so raising
|
|
73
|
+
// POSTGRES_CONNECTION_TIMEOUT_MS cannot silently leave the budget short. It
|
|
74
|
+
// has a ceiling as well as a floor: Node-RED gives each node ~15s to close, so
|
|
75
|
+
// POSTGRES_CONNECTION_TIMEOUT_MS must stay well under 14s or this budget stops
|
|
76
|
+
// bounding anything and Node-RED's own timeout cuts the close off instead.
|
|
77
|
+
const POSTGRES_CLOSE_MS = POSTGRES_CONNECTION_TIMEOUT_MS + CLOSE_GRACE_MS;
|
|
60
78
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
function closeBudgetFor(connection) {
|
|
80
|
+
// PostgreSQL owners have no companion connection in node.resources because
|
|
81
|
+
// BullMQ owns their pool. Redis owners retain the live ioredis status that
|
|
82
|
+
// distinguishes a healthy worker drain from an unreachable fast close.
|
|
83
|
+
if (!connection) {
|
|
84
|
+
return POSTGRES_CLOSE_MS;
|
|
85
|
+
}
|
|
86
|
+
return connection.status === "ready" ? GRACEFUL_CLOSE_MS : CLOSE_GRACE_MS;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function settled(promise) {
|
|
90
|
+
try {
|
|
91
|
+
await promise;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// a failed graceful close still counts as settled
|
|
94
|
+
}
|
|
95
|
+
return "settled";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function settleWithin(promise, ms) {
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
try {
|
|
101
|
+
return await Promise.race([
|
|
102
|
+
settled(promise),
|
|
103
|
+
sleep(ms, "timeout", { signal: controller.signal }),
|
|
104
|
+
]);
|
|
105
|
+
} finally {
|
|
106
|
+
controller.abort();
|
|
64
107
|
}
|
|
65
|
-
return settled ? "settled" : "timeout";
|
|
66
108
|
}
|
|
67
109
|
|
|
68
110
|
function disconnectClient(client) {
|
|
@@ -75,7 +117,7 @@ function disconnectClient(client) {
|
|
|
75
117
|
}
|
|
76
118
|
}
|
|
77
119
|
|
|
78
|
-
function forceDisconnect(resource) {
|
|
120
|
+
async function forceDisconnect(resource) {
|
|
79
121
|
if (!resource) {
|
|
80
122
|
return;
|
|
81
123
|
}
|
|
@@ -84,18 +126,44 @@ function forceDisconnect(resource) {
|
|
|
84
126
|
disconnectClient(resource);
|
|
85
127
|
return;
|
|
86
128
|
}
|
|
87
|
-
// BullMQ
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
129
|
+
// BullMQ 6.3.1's public disconnect() can await the same never-ready promise
|
|
130
|
+
// as close(), which would spend a second shutdown budget after the first one
|
|
131
|
+
// already expired. The exact BullMQ pin makes this one backend escape hatch
|
|
132
|
+
// deliberate and testable until upstream disconnect becomes bounded.
|
|
133
|
+
if (typeof resource.getBackend !== "function") {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const backend = resource.getBackend();
|
|
137
|
+
// RedisQueueBackend is the only installed backend exposing raw `_client`
|
|
138
|
+
// handles. PostgreSQL deliberately skips this branch.
|
|
139
|
+
if (backend && backend.connection && backend.connection._client) {
|
|
140
|
+
disconnectClient(backend.connection._client);
|
|
141
|
+
disconnectClient(
|
|
142
|
+
backend.blockingConnection && backend.blockingConnection._client,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
// Measured on installed BullMQ 6.3.1: a Worker's close() never reaches this
|
|
146
|
+
// point on its own here, because its very first cleanup step awaits the
|
|
147
|
+
// same stuck connection above. That means the lock-renewal timer it starts
|
|
148
|
+
// on construction (independent of connection state) is never cancelled by
|
|
149
|
+
// Worker's own close() -- stop it here directly so it cannot outlive the
|
|
150
|
+
// resource and keep the process alive.
|
|
151
|
+
if (resource.lockManager && typeof resource.lockManager.close === "function") {
|
|
152
|
+
await resource.lockManager.close();
|
|
92
153
|
}
|
|
93
|
-
|
|
94
|
-
|
|
154
|
+
// Same reasoning for the stalled-job checker: Worker.close() only stops it
|
|
155
|
+
// after the cleanup step that hung above, and each check reschedules its own
|
|
156
|
+
// timer. This one is only live if the worker processed at least once before
|
|
157
|
+
// Redis went away -- the common production case, unlike a connection that was
|
|
158
|
+
// never reachable at all.
|
|
159
|
+
if (typeof resource.stalledCheckStopper === "function") {
|
|
160
|
+
resource.stalledCheckStopper();
|
|
95
161
|
}
|
|
162
|
+
// PostgreSQL has no raw force-disconnect branch. closeBudgetFor therefore
|
|
163
|
+
// lets its own connection timeout settle close() before Node-RED calls done.
|
|
96
164
|
}
|
|
97
165
|
|
|
98
|
-
async function closeResource(resource) {
|
|
166
|
+
async function closeResource(resource, connection) {
|
|
99
167
|
if (!resource) {
|
|
100
168
|
return;
|
|
101
169
|
}
|
|
@@ -106,19 +174,39 @@ async function closeResource(resource) {
|
|
|
106
174
|
// connections and force-disconnect everything else.
|
|
107
175
|
if (resource.status === "ready" && typeof resource.quit === "function") {
|
|
108
176
|
if ((await settleWithin(resource.quit(), CLOSE_GRACE_MS)) === "timeout") {
|
|
109
|
-
forceDisconnect(resource);
|
|
177
|
+
await forceDisconnect(resource);
|
|
110
178
|
}
|
|
111
179
|
} else {
|
|
112
|
-
forceDisconnect(resource);
|
|
180
|
+
await forceDisconnect(resource);
|
|
113
181
|
}
|
|
114
182
|
return;
|
|
115
183
|
}
|
|
116
184
|
|
|
117
185
|
// BullMQ resource: QueueEvents.close() blocks forever on a connection that
|
|
118
|
-
// never became ready, so cap the graceful close and force-disconnect
|
|
119
|
-
//
|
|
120
|
-
if (
|
|
121
|
-
|
|
186
|
+
// never became ready, so cap the graceful close and force-disconnect when
|
|
187
|
+
// it does not settle in time (see forceDisconnect for the fallback chain).
|
|
188
|
+
if (
|
|
189
|
+
(await settleWithin(resource.close(), closeBudgetFor(connection))) ===
|
|
190
|
+
"timeout"
|
|
191
|
+
) {
|
|
192
|
+
await forceDisconnect(resource);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function closeResourcePair(owner, connection) {
|
|
197
|
+
let firstError;
|
|
198
|
+
try {
|
|
199
|
+
await closeResource(owner, connection);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
firstError = err;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
await closeResource(connection);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
firstError ||= err;
|
|
207
|
+
}
|
|
208
|
+
if (firstError) {
|
|
209
|
+
throw firstError;
|
|
122
210
|
}
|
|
123
211
|
}
|
|
124
212
|
|
|
@@ -134,13 +222,73 @@ function nodeDone(node, done, err, msg) {
|
|
|
134
222
|
}
|
|
135
223
|
}
|
|
136
224
|
|
|
137
|
-
|
|
138
|
-
|
|
225
|
+
// FlowProducer.addBulk(flows) accepts no options argument, so the queuesOptions
|
|
226
|
+
// route withFlowJobDefaults() uses for add() is unavailable here. Stamp the
|
|
227
|
+
// retention onto each job's own opts instead; anything the caller set wins.
|
|
228
|
+
function withBulkFlowJobDefaults(flow, defaultJobOptions) {
|
|
229
|
+
if (!defaultJobOptions || !flow || typeof flow !== "object") {
|
|
230
|
+
return flow;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const children = Array.isArray(flow.children)
|
|
234
|
+
? flow.children.map((child) =>
|
|
235
|
+
withBulkFlowJobDefaults(child, defaultJobOptions),
|
|
236
|
+
)
|
|
237
|
+
: flow.children;
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
...flow,
|
|
241
|
+
opts: { ...defaultJobOptions, ...flow.opts },
|
|
242
|
+
...(children === undefined ? {} : { children }),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function withFlowJobDefaults(flow, flowOptions, defaultJobOptions) {
|
|
247
|
+
if (!defaultJobOptions) {
|
|
248
|
+
return flowOptions;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const options =
|
|
252
|
+
flowOptions && typeof flowOptions === "object" ? flowOptions : {};
|
|
253
|
+
// Already a fresh copy, so later queues are assigned into it rather than
|
|
254
|
+
// re-spreading the whole map per queue name.
|
|
255
|
+
const queuesOptions = { ...options.queuesOptions };
|
|
256
|
+
const pending = [flow];
|
|
257
|
+
|
|
258
|
+
while (pending.length > 0) {
|
|
259
|
+
const job = pending.pop();
|
|
260
|
+
if (!job || typeof job !== "object") {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (typeof job.queueName === "string" && job.queueName) {
|
|
264
|
+
const queueOptions = queuesOptions[job.queueName] || {};
|
|
265
|
+
queuesOptions[job.queueName] = {
|
|
266
|
+
...queueOptions,
|
|
267
|
+
defaultJobOptions: {
|
|
268
|
+
...defaultJobOptions,
|
|
269
|
+
...queueOptions.defaultJobOptions,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (Array.isArray(job.children)) {
|
|
274
|
+
pending.push(...job.children);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { ...options, queuesOptions };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isPresent(value) {
|
|
282
|
+
return value !== undefined && value !== null && value !== "";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parsePositiveInteger(value, defaultValue, field = "Value") {
|
|
286
|
+
if (!isPresent(value)) {
|
|
139
287
|
return defaultValue;
|
|
140
288
|
}
|
|
141
289
|
const parsed = Number(value);
|
|
142
290
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
143
|
-
throw new Error(
|
|
291
|
+
throw new Error(`${field} must be a positive integer`);
|
|
144
292
|
}
|
|
145
293
|
return parsed;
|
|
146
294
|
}
|
|
@@ -169,14 +317,60 @@ function setDisconnected(node) {
|
|
|
169
317
|
node.status({ fill: "red", shape: "ring", text: "disconnected" });
|
|
170
318
|
}
|
|
171
319
|
|
|
172
|
-
function attachErrorListener(resource, node) {
|
|
320
|
+
function attachErrorListener(resource, node, startupFailureOwner) {
|
|
173
321
|
if (!resource || typeof resource.on !== "function") {
|
|
174
|
-
return;
|
|
322
|
+
return () => {};
|
|
175
323
|
}
|
|
324
|
+
let ready = false;
|
|
176
325
|
resource.on("error", (err) => {
|
|
177
326
|
setDisconnected(node);
|
|
178
|
-
|
|
327
|
+
if (startupFailureOwner && !ready) {
|
|
328
|
+
startupFailureOwner.reportBackendFailure(err);
|
|
329
|
+
} else {
|
|
330
|
+
node.error(err);
|
|
331
|
+
}
|
|
179
332
|
});
|
|
333
|
+
return function markReady() {
|
|
334
|
+
ready = true;
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Turns a rejected backend readiness promise -- or a synchronous resource
|
|
339
|
+
// construction failure, since BullMQ's postgres factory validates and loads
|
|
340
|
+
// its optional `pg` dependency before any I/O -- into one of a handful of
|
|
341
|
+
// actionable categories, so a Node-RED user is told what to DO rather than
|
|
342
|
+
// just that the backend is unavailable. The category string is also the
|
|
343
|
+
// dedup key each config node latches on; see node.reportBackendFailure.
|
|
344
|
+
function describeBackendFailure(err) {
|
|
345
|
+
const message = err && err.message ? err.message : String(err);
|
|
346
|
+
if (err instanceof SchemaMigrationRequiredError) {
|
|
347
|
+
return {
|
|
348
|
+
category: "schema-migration-required",
|
|
349
|
+
message: `${message} Set the queue configuration node's "migrate" property to true so this node initialises the schema, or run BullMQ's PostgreSQL migrations against the database yourself before deploying.`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (err instanceof SchemaVersionMismatchError) {
|
|
353
|
+
return {
|
|
354
|
+
category: "schema-version-mismatch",
|
|
355
|
+
message: `${message} Upgrade this Node-RED package to a release that uses the required BullMQ major; PostgreSQL schema downgrades are not supported.`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (err instanceof UnsupportedPostgresVersionError) {
|
|
359
|
+
return {
|
|
360
|
+
category: "unsupported-postgres-version",
|
|
361
|
+
message: `${message} BullMQ's PostgreSQL backend requires server version ${MINIMUM_POSTGRES_VERSION} or newer (${RECOMMENDED_POSTGRES_VERSION}+ recommended).`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// BullMQ lazily requires the optional `pg` package and, when it cannot be
|
|
365
|
+
// resolved, throws its own actionable error rather than a raw
|
|
366
|
+
// MODULE_NOT_FOUND -- reuse that message instead of wrapping it again.
|
|
367
|
+
if (message.includes("npm install pg")) {
|
|
368
|
+
return { category: "pg-missing", message };
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
category: "generic",
|
|
372
|
+
message: `BullMQ backend is unavailable: ${message}`,
|
|
373
|
+
};
|
|
180
374
|
}
|
|
181
375
|
|
|
182
376
|
function createJobMessage(job, queueName, extraBull = {}) {
|
|
@@ -202,24 +396,60 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
202
396
|
RED.nodes.createNode(this, n);
|
|
203
397
|
const node = this;
|
|
204
398
|
|
|
205
|
-
node.
|
|
206
|
-
node.resources = new Set();
|
|
399
|
+
node.resources = new Map();
|
|
207
400
|
node.config = normalizeQueueConfig(n, node.credentials || {});
|
|
208
401
|
node.queue = null;
|
|
209
402
|
node.producerConnection = null;
|
|
403
|
+
node.telemetry = undefined;
|
|
404
|
+
node.telemetryUnavailable = false;
|
|
210
405
|
|
|
211
|
-
node.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
406
|
+
// Categories already reported for this config node. BullMQ builds one
|
|
407
|
+
// backend per resource (Queue/Worker/QueueEvents/FlowProducer each get
|
|
408
|
+
// their own pool), so the same misconfiguration can be observed from
|
|
409
|
+
// more than one of them -- this latch is what keeps one deploy at one
|
|
410
|
+
// report per distinct failure instead of one per resource that hit it.
|
|
411
|
+
node.backendFailures = new Set();
|
|
412
|
+
node.reportBackendFailure = function reportBackendFailure(err) {
|
|
413
|
+
const { category, message } = describeBackendFailure(err);
|
|
414
|
+
if (node.backendFailures.has(category)) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
node.backendFailures.add(category);
|
|
418
|
+
node.error(message);
|
|
218
419
|
};
|
|
219
420
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
421
|
+
// Lazily constructs (and caches) the one BullMQOtel instance shared by
|
|
422
|
+
// this config node's Queue/Worker/FlowProducer. Deferred until first
|
|
423
|
+
// resource creation rather than built in the constructor above: Node-RED
|
|
424
|
+
// builds config nodes at deploy time, which can precede the host's
|
|
425
|
+
// OpenTelemetry bootstrap, and enableMetrics needs a MeterProvider
|
|
426
|
+
// registered before BullMQOtel is constructed.
|
|
427
|
+
node.getTelemetry = function getTelemetry() {
|
|
428
|
+
if (!node.config.telemetry) {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
if (node.telemetry || node.telemetryUnavailable) {
|
|
432
|
+
return node.telemetry;
|
|
433
|
+
}
|
|
434
|
+
let BullMQOtel;
|
|
435
|
+
try {
|
|
436
|
+
({ BullMQOtel } = require("bullmq-otel"));
|
|
437
|
+
} catch (err) {
|
|
438
|
+
node.telemetryUnavailable = true;
|
|
439
|
+
node.error(
|
|
440
|
+
"BullMQ telemetry is enabled but bullmq-otel is not installed; run npm install bullmq-otel",
|
|
441
|
+
);
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
const serviceName =
|
|
445
|
+
node.config.telemetryServiceName || node.config.queueName;
|
|
446
|
+
node.telemetry = new BullMQOtel({
|
|
447
|
+
tracerName: serviceName,
|
|
448
|
+
meterName: serviceName,
|
|
449
|
+
version: PACKAGE_VERSION,
|
|
450
|
+
enableMetrics: node.config.telemetryMetrics,
|
|
451
|
+
});
|
|
452
|
+
return node.telemetry;
|
|
223
453
|
};
|
|
224
454
|
|
|
225
455
|
// owner is the node whose status should reflect connection errors. It
|
|
@@ -229,70 +459,201 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
229
459
|
const descriptor = buildRedisDescriptor(node.config, role);
|
|
230
460
|
const connection = createRedisConnection(descriptor, IORedis);
|
|
231
461
|
attachErrorListener(connection, owner);
|
|
232
|
-
node.resources.add(connection);
|
|
233
462
|
return connection;
|
|
234
463
|
};
|
|
235
464
|
|
|
465
|
+
node.releaseResource = async function releaseResource(owner) {
|
|
466
|
+
if (!node.resources.has(owner)) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const connection = node.resources.get(owner);
|
|
470
|
+
node.resources.delete(owner);
|
|
471
|
+
await closeResourcePair(owner, connection);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// The four BullMQ owners differ only in which constructor they call and
|
|
475
|
+
// where that constructor takes the backend factory: Queue and QueueEvents
|
|
476
|
+
// take it 3rd, Worker 4th, FlowProducer 2nd. Everything around that is the
|
|
477
|
+
// same work, so `construct` receives the built options plus the factory to
|
|
478
|
+
// pass on -- undefined on Redis, where every one of those constructors
|
|
479
|
+
// defaults the parameter to BullMQ's own Redis factory.
|
|
480
|
+
//
|
|
481
|
+
// On PostgreSQL, BullMQ's factory validates the schema name and
|
|
482
|
+
// synchronously loads the optional `pg` module (createPostgresBackend ->
|
|
483
|
+
// new PostgresConnection) before any resource exists, so a bad schema name
|
|
484
|
+
// or a missing `pg` install throws out of `new Queue/Worker/QueueEvents/
|
|
485
|
+
// FlowProducer(...)` itself rather than through waitUntilReady(). Catch it
|
|
486
|
+
// here instead of crashing the owning node's constructor.
|
|
487
|
+
function createResource(role, owner, telemetry, construct, extraOptions) {
|
|
488
|
+
// BullMQ owns every PostgreSQL connection (a pool plus a dedicated
|
|
489
|
+
// LISTEN client per backend); this package builds none of its own on
|
|
490
|
+
// that path, so createConnection is skipped entirely.
|
|
491
|
+
const isPostgres = node.config.backend === "postgres";
|
|
492
|
+
const connection = isPostgres
|
|
493
|
+
? undefined
|
|
494
|
+
: node.createConnection(role, owner);
|
|
495
|
+
const options = {
|
|
496
|
+
...buildBullMQOptions(
|
|
497
|
+
node.config,
|
|
498
|
+
isPostgres ? node.config.postgres : connection,
|
|
499
|
+
telemetry,
|
|
500
|
+
role,
|
|
501
|
+
),
|
|
502
|
+
...extraOptions,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
let resource;
|
|
506
|
+
if (isPostgres) {
|
|
507
|
+
try {
|
|
508
|
+
resource = construct(options, createPostgresBackend);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
publishBackendStatus("disconnected");
|
|
511
|
+
node.reportBackendFailure(err);
|
|
512
|
+
return { resource: undefined, connection };
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
resource = construct(options);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// On postgres there is no connection of our own -- the owner is tracked
|
|
519
|
+
// with no value so close/redeploy still walks it, but has nothing raw to
|
|
520
|
+
// close.
|
|
521
|
+
node.resources.set(resource, connection);
|
|
522
|
+
return { resource, connection };
|
|
523
|
+
}
|
|
524
|
+
|
|
236
525
|
node.getQueue = function getQueue() {
|
|
237
526
|
if (!node.queue) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
node
|
|
241
|
-
|
|
527
|
+
const { resource, connection } = createResource(
|
|
528
|
+
"producer",
|
|
529
|
+
node,
|
|
530
|
+
node.getTelemetry(),
|
|
531
|
+
(options, factory) =>
|
|
532
|
+
new Queue(node.config.queueName, options, factory),
|
|
533
|
+
// Queue-level auto-removal. Without it BullMQ keeps every completed
|
|
534
|
+
// and failed job forever, which is the unbounded growth its
|
|
535
|
+
// production guide warns about. Per-job msg.jobopts still wins.
|
|
536
|
+
node.config.defaultJobOptions
|
|
537
|
+
? { defaultJobOptions: node.config.defaultJobOptions }
|
|
538
|
+
: undefined,
|
|
242
539
|
);
|
|
540
|
+
if (!resource) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
node.queue = resource;
|
|
544
|
+
node.producerConnection = connection || null;
|
|
545
|
+
node.watchBackend(node.queue.getBackend());
|
|
243
546
|
attachErrorListener(node.queue, node);
|
|
244
547
|
}
|
|
245
548
|
return node.queue;
|
|
246
549
|
};
|
|
247
550
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
551
|
+
// Live reachability of the shared backend, owned here rather than read by
|
|
552
|
+
// each bullmq cmd node. waitUntilReady() cannot answer "is it reachable
|
|
553
|
+
// now": RedisQueueBackend awaits connection.client, which returns the
|
|
554
|
+
// promise built once in the constructor, and PostgresConnection memoizes
|
|
555
|
+
// readyPromise the same way. So a node deployed after an outage began would
|
|
556
|
+
// see that resolved promise and paint a false "connected". The backend's
|
|
557
|
+
// ready/error/close events are live, so they own the state after the first
|
|
558
|
+
// observation and every reader sees the current value.
|
|
559
|
+
node.backendStatus = "connecting";
|
|
560
|
+
node.backendReaders = new Set();
|
|
561
|
+
|
|
562
|
+
function publishBackendStatus(status) {
|
|
563
|
+
node.backendStatus = status;
|
|
564
|
+
for (const read of node.backendReaders) {
|
|
565
|
+
read(status);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
node.watchBackend = function watchBackend(backend) {
|
|
570
|
+
backend.on("ready", () => publishBackendStatus("connected"));
|
|
571
|
+
backend.on("error", () => publishBackendStatus("disconnected"));
|
|
572
|
+
backend.on("close", () => publishBackendStatus("disconnected"));
|
|
573
|
+
// Seed the first observation once, for the whole config node. An async
|
|
574
|
+
// IIFE rather than a promise chain (forbidden in this file) so it never
|
|
575
|
+
// blocks deploy, and it must not overwrite a live event that already
|
|
576
|
+
// told us more than this memoized promise can.
|
|
577
|
+
(async () => {
|
|
578
|
+
try {
|
|
579
|
+
await backend.waitUntilReady();
|
|
580
|
+
if (node.backendStatus === "connecting") {
|
|
581
|
+
publishBackendStatus("connected");
|
|
582
|
+
}
|
|
583
|
+
} catch (err) {
|
|
584
|
+
// Do NOT assume an event already covered this. PostgresConnection's
|
|
585
|
+
// bootstrap() rejects on connect, auth, migration, or schema failure
|
|
586
|
+
// and emits nothing (its emitError only forwards idle-pool and LISTEN
|
|
587
|
+
// errors, and only when a listener is already attached), and Queue's
|
|
588
|
+
// constructor swallows the same rejection. Without this the node
|
|
589
|
+
// would sit on "connecting" forever with nothing reported. The
|
|
590
|
+
// sibling bullmq events and bullmq flow nodes handle their own
|
|
591
|
+
// waitUntilReady() rejection through node.reportBackendFailure too,
|
|
592
|
+
// so the same failure seen from more than one resource still lands
|
|
593
|
+
// as one report.
|
|
594
|
+
if (node.backendStatus === "connecting") {
|
|
595
|
+
publishBackendStatus("disconnected");
|
|
596
|
+
node.reportBackendFailure(err);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
})();
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Readers get the current status immediately, which is what makes a
|
|
603
|
+
// late-deployed node correct: the shared state is live, unlike the
|
|
604
|
+
// already-fired "ready" event it would otherwise have missed.
|
|
605
|
+
node.readBackendStatus = function readBackendStatus(read) {
|
|
606
|
+
node.backendReaders.add(read);
|
|
607
|
+
read(node.backendStatus);
|
|
608
|
+
return function stopReading() {
|
|
609
|
+
node.backendReaders.delete(read);
|
|
610
|
+
};
|
|
253
611
|
};
|
|
254
612
|
|
|
255
613
|
// Runtime nodes pass themselves as owner and attach their own resource
|
|
256
614
|
// error listener, so worker/events/flow errors surface on the visible
|
|
257
615
|
// runtime node rather than the hidden config node.
|
|
258
616
|
node.createWorker = function createWorker(processor, options, owner = node) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
617
|
+
return createResource(
|
|
618
|
+
"worker",
|
|
619
|
+
owner,
|
|
620
|
+
node.getTelemetry(),
|
|
621
|
+
(workerOptions, factory) =>
|
|
622
|
+
new Worker(node.config.queueName, processor, workerOptions, factory),
|
|
623
|
+
options,
|
|
624
|
+
).resource;
|
|
266
625
|
};
|
|
267
626
|
|
|
268
627
|
node.createQueueEvents = function createQueueEvents(owner = node) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
628
|
+
// QueueEvents deliberately gets no telemetry instance; see docs/TELEMETRY.md.
|
|
629
|
+
return createResource(
|
|
630
|
+
"events",
|
|
631
|
+
owner,
|
|
632
|
+
undefined,
|
|
633
|
+
(options, factory) =>
|
|
634
|
+
new QueueEvents(node.config.queueName, options, factory),
|
|
635
|
+
).resource;
|
|
276
636
|
};
|
|
277
637
|
|
|
278
638
|
node.createFlowProducer = function createFlowProducer(owner = node) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
639
|
+
return createResource(
|
|
640
|
+
"producer",
|
|
641
|
+
owner,
|
|
642
|
+
node.getTelemetry(),
|
|
643
|
+
(options, factory) => new FlowProducer(options, factory),
|
|
644
|
+
).resource;
|
|
285
645
|
};
|
|
286
646
|
|
|
287
647
|
node.on("close", async function onClose(removed, done) {
|
|
288
648
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
649
|
+
node.backendFailures.clear();
|
|
650
|
+
const resources = Array.from(node.resources.entries()).reverse();
|
|
651
|
+
node.resources.clear();
|
|
652
|
+
await Promise.all(
|
|
653
|
+
resources.map(([owner, connection]) =>
|
|
654
|
+
closeResourcePair(owner, connection)
|
|
655
|
+
)
|
|
656
|
+
);
|
|
296
657
|
node.status({});
|
|
297
658
|
done();
|
|
298
659
|
} catch (err) {
|
|
@@ -301,7 +662,7 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
301
662
|
});
|
|
302
663
|
}
|
|
303
664
|
|
|
304
|
-
RED.nodes.registerType("
|
|
665
|
+
RED.nodes.registerType("bullmq-queue-server", BullQueueServerSetup, {
|
|
305
666
|
credentials: {
|
|
306
667
|
password: { type: "password" },
|
|
307
668
|
sentinelPassword: { type: "password" },
|
|
@@ -319,32 +680,44 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
319
680
|
|
|
320
681
|
if (!node.bullConn) {
|
|
321
682
|
node.status({ fill: "red", shape: "ring", text: "missing queue" });
|
|
322
|
-
node.error("Missing
|
|
683
|
+
node.error("Missing bullmq-queue-server config node");
|
|
323
684
|
return;
|
|
324
685
|
}
|
|
325
686
|
|
|
326
|
-
node.
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
687
|
+
// Mirror the config node's live view of the shared backend. Reading the
|
|
688
|
+
// shared state rather than subscribing to the backend directly keeps the
|
|
689
|
+
// listener count on the backend constant no matter how many bullmq cmd
|
|
690
|
+
// nodes a flow has, and gives a node deployed mid-outage the current
|
|
691
|
+
// status instead of a stale one.
|
|
692
|
+
const applyBackendStatus = (status) => {
|
|
693
|
+
if (status === "connected") {
|
|
694
|
+
setConnected(node);
|
|
695
|
+
} else if (status === "disconnected") {
|
|
696
|
+
setDisconnected(node);
|
|
697
|
+
} else {
|
|
698
|
+
setConnecting(node);
|
|
699
|
+
}
|
|
335
700
|
};
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
701
|
+
// Create the shared queue up front, as this node has always done, so the
|
|
702
|
+
// status reflects real reachability instead of a static "configured" the
|
|
703
|
+
// moment the flow deploys. This is the call that builds the queue, its
|
|
704
|
+
// connection, and (once PostgreSQL is wired) its pool; reading the status
|
|
705
|
+
// afterwards only subscribes.
|
|
706
|
+
node.bullConn.getQueue();
|
|
707
|
+
const stopReadingBackend =
|
|
708
|
+
node.bullConn.readBackendStatus(applyBackendStatus);
|
|
345
709
|
node.on("input", async function onInput(msg, send, done) {
|
|
346
710
|
try {
|
|
347
|
-
const
|
|
711
|
+
const queue = node.bullConn.getQueue();
|
|
712
|
+
if (!queue) {
|
|
713
|
+
// Construction failed synchronously (missing pg, bad schema name).
|
|
714
|
+
// The config node already reported why; say something useful here
|
|
715
|
+
// rather than letting queue.add() throw a bare TypeError per message.
|
|
716
|
+
throw new Error(
|
|
717
|
+
"BullMQ queue is unavailable; see the queue configuration node's error",
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
const result = await dispatchCommand(queue, msg);
|
|
348
721
|
msg.payload = result;
|
|
349
722
|
nodeSend(node, send, msg);
|
|
350
723
|
nodeDone(node, done);
|
|
@@ -354,10 +727,8 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
354
727
|
});
|
|
355
728
|
|
|
356
729
|
node.on("close", function onClose(removed, done) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
node.bullConn.deregister(node, done);
|
|
730
|
+
stopReadingBackend();
|
|
731
|
+
done();
|
|
361
732
|
});
|
|
362
733
|
}
|
|
363
734
|
|
|
@@ -370,23 +741,48 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
370
741
|
|
|
371
742
|
if (!node.bullQueue) {
|
|
372
743
|
node.status({ fill: "red", shape: "ring", text: "missing queue" });
|
|
373
|
-
node.error("Missing
|
|
744
|
+
node.error("Missing bullmq-queue-server config node");
|
|
374
745
|
return;
|
|
375
746
|
}
|
|
376
747
|
|
|
377
|
-
node.bullQueue.register(node);
|
|
378
|
-
|
|
379
748
|
const workerOptions = {
|
|
380
|
-
concurrency: parsePositiveInteger(n.concurrency, 1),
|
|
749
|
+
concurrency: parsePositiveInteger(n.concurrency, 1, "Concurrency"),
|
|
750
|
+
maxStartedAttempts: parsePositiveInteger(
|
|
751
|
+
n.maxStartedAttempts,
|
|
752
|
+
100,
|
|
753
|
+
"Max Started Attempts"
|
|
754
|
+
),
|
|
381
755
|
};
|
|
382
|
-
|
|
756
|
+
const hasLimiterMax = isPresent(n.limiterMax);
|
|
757
|
+
const hasLimiterDuration = isPresent(n.limiterDuration);
|
|
758
|
+
if (hasLimiterMax !== hasLimiterDuration) {
|
|
759
|
+
throw new Error("Limiter Max and Limiter Duration must be set together");
|
|
760
|
+
}
|
|
761
|
+
if (hasLimiterMax) {
|
|
383
762
|
workerOptions.limiter = {
|
|
384
|
-
max: parsePositiveInteger(n.limiterMax),
|
|
385
|
-
duration: parsePositiveInteger(
|
|
763
|
+
max: parsePositiveInteger(n.limiterMax, undefined, "Limiter Max"),
|
|
764
|
+
duration: parsePositiveInteger(
|
|
765
|
+
n.limiterDuration,
|
|
766
|
+
undefined,
|
|
767
|
+
"Limiter Duration"
|
|
768
|
+
),
|
|
386
769
|
};
|
|
387
770
|
}
|
|
771
|
+
if (node.bullQueue.config.backend === "postgres") {
|
|
772
|
+
// A permanent readiness error (for example an unmigrated schema) makes
|
|
773
|
+
// BullMQ's autorun loop retry without delay. Start only after the one
|
|
774
|
+
// readiness promise succeeds so a configuration error cannot spin the
|
|
775
|
+
// Node-RED runtime.
|
|
776
|
+
workerOptions.autorun = false;
|
|
777
|
+
}
|
|
388
778
|
|
|
389
|
-
|
|
779
|
+
// Arity 3 tells BullMQ to create and track a per-job AbortController
|
|
780
|
+
// (worker.js: processorAcceptsSignal = processor.length >= 3), which is
|
|
781
|
+
// what makes cancelJob/cancelAllJobs able to reach this job at all.
|
|
782
|
+
// The token is the job's lock: bullmq job needs it for moveToWait and
|
|
783
|
+
// moveToDelayed, and it stays in the acknowledgement registry -- never in
|
|
784
|
+
// a message.
|
|
785
|
+
const processor = async (job, token, signal) => {
|
|
390
786
|
if (node.completionMode === "manual") {
|
|
391
787
|
const timeoutMs = parseAckTimeoutMs(n.ackTimeout);
|
|
392
788
|
const acknowledgement = acknowledgements.create(
|
|
@@ -395,6 +791,9 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
395
791
|
queue: node.bullQueue.getQueue(),
|
|
396
792
|
queueName: node.bullQueue.config.queueName,
|
|
397
793
|
runNodeId: node.id,
|
|
794
|
+
worker: node.worker,
|
|
795
|
+
signal,
|
|
796
|
+
token,
|
|
398
797
|
},
|
|
399
798
|
timeoutMs
|
|
400
799
|
);
|
|
@@ -413,8 +812,52 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
413
812
|
};
|
|
414
813
|
|
|
415
814
|
node.worker = node.bullQueue.createWorker(processor, workerOptions, node);
|
|
416
|
-
|
|
417
|
-
|
|
815
|
+
// createWorker reports and returns undefined on a synchronous backend
|
|
816
|
+
// construction failure (e.g. postgres selected with pg not installed);
|
|
817
|
+
// nothing was created, so there is nothing to close on this node's own
|
|
818
|
+
// close, and no listener setup below would have anything to attach to.
|
|
819
|
+
if (!node.worker) {
|
|
820
|
+
setDisconnected(node);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const workerStartupFailureOwner =
|
|
824
|
+
node.bullQueue.config.backend === "postgres" ? node.bullQueue : undefined;
|
|
825
|
+
const markWorkerReady = attachErrorListener(
|
|
826
|
+
node.worker,
|
|
827
|
+
node,
|
|
828
|
+
workerStartupFailureOwner,
|
|
829
|
+
);
|
|
830
|
+
if (workerStartupFailureOwner) {
|
|
831
|
+
let workerReady = false;
|
|
832
|
+
async function startPostgresWorker() {
|
|
833
|
+
try {
|
|
834
|
+
await node.worker.waitUntilReady();
|
|
835
|
+
if (node.worker.closing) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
workerReady = true;
|
|
839
|
+
markWorkerReady();
|
|
840
|
+
setConnected(node);
|
|
841
|
+
await node.worker.run();
|
|
842
|
+
} catch (err) {
|
|
843
|
+
if (node.worker.closing) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
setDisconnected(node);
|
|
847
|
+
if (workerReady) {
|
|
848
|
+
node.error(err);
|
|
849
|
+
} else {
|
|
850
|
+
workerStartupFailureOwner.reportBackendFailure(err);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
void startPostgresWorker();
|
|
855
|
+
} else {
|
|
856
|
+
node.worker.on("ready", () => {
|
|
857
|
+
markWorkerReady();
|
|
858
|
+
setConnected(node);
|
|
859
|
+
});
|
|
860
|
+
}
|
|
418
861
|
node.worker.on("closed", () => setDisconnected(node));
|
|
419
862
|
setConnecting(node);
|
|
420
863
|
|
|
@@ -424,8 +867,7 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
424
867
|
new Error("BullMQ run node closed before acknowledgement")
|
|
425
868
|
);
|
|
426
869
|
try {
|
|
427
|
-
await
|
|
428
|
-
node.bullQueue.deregister(node, () => {});
|
|
870
|
+
await node.bullQueue.releaseResource(node.worker);
|
|
429
871
|
done();
|
|
430
872
|
} catch (err) {
|
|
431
873
|
done(err);
|
|
@@ -503,8 +945,63 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
503
945
|
context.fail(Worker.RateLimitError());
|
|
504
946
|
nodeDone(node, done);
|
|
505
947
|
return;
|
|
948
|
+
// Step/retry transitions. Each hands the job's lock token back to
|
|
949
|
+
// BullMQ and then settles the acknowledgement with the error class
|
|
950
|
+
// the worker special-cases, so the job is NOT moved to failed
|
|
951
|
+
// (worker.js checks DelayedError and WaitingError).
|
|
952
|
+
case "moveToWait": {
|
|
953
|
+
await context.job.moveToWait(context.token);
|
|
954
|
+
context.fail(new WaitingError());
|
|
955
|
+
msg.payload = true;
|
|
956
|
+
nodeSend(node, send, msg);
|
|
957
|
+
nodeDone(node, done);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
case "moveToDelayed": {
|
|
961
|
+
const delay = Number(msg.delay);
|
|
962
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
963
|
+
throw new Error(
|
|
964
|
+
"moveToDelayed requires msg.delay in milliseconds"
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
await context.job.moveToDelayed(Date.now() + delay, context.token);
|
|
968
|
+
context.fail(new DelayedError());
|
|
969
|
+
msg.payload = true;
|
|
970
|
+
nodeSend(node, send, msg);
|
|
971
|
+
nodeDone(node, done);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
case "updateData":
|
|
975
|
+
await context.job.updateData(
|
|
976
|
+
Object.hasOwn(msg, "jobData") ? msg.jobData : msg.payload
|
|
977
|
+
);
|
|
978
|
+
msg.payload = serializeJob(context.job);
|
|
979
|
+
nodeSend(node, send, msg);
|
|
980
|
+
nodeDone(node, done);
|
|
981
|
+
return;
|
|
982
|
+
case "cancelJob": {
|
|
983
|
+
const reason = msg.reason || "BullMQ job cancelled";
|
|
984
|
+
const cancelled = context.worker.cancelJob(context.job.id, reason);
|
|
985
|
+
if (!cancelled) {
|
|
986
|
+
throw new Error(
|
|
987
|
+
`BullMQ found no cancellable processor for job ${context.job.id}`
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
msg.payload = cancelled;
|
|
991
|
+
nodeSend(node, send, msg);
|
|
992
|
+
nodeDone(node, done);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
case "cancelAllJobs": {
|
|
996
|
+
const reason = msg.reason || "BullMQ job cancelled";
|
|
997
|
+
context.worker.cancelAllJobs(reason);
|
|
998
|
+
msg.payload = true;
|
|
999
|
+
nodeSend(node, send, msg);
|
|
1000
|
+
nodeDone(node, done);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
506
1003
|
default:
|
|
507
|
-
throw new Error(`Unsupported
|
|
1004
|
+
throw new Error(`Unsupported bullmq job action: ${action}`);
|
|
508
1005
|
}
|
|
509
1006
|
} catch (err) {
|
|
510
1007
|
nodeDone(node, done, err, msg);
|
|
@@ -520,13 +1017,23 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
520
1017
|
|
|
521
1018
|
if (!node.bullConn) {
|
|
522
1019
|
node.status({ fill: "red", shape: "ring", text: "missing queue" });
|
|
523
|
-
node.error("Missing
|
|
1020
|
+
node.error("Missing bullmq-queue-server config node");
|
|
524
1021
|
return;
|
|
525
1022
|
}
|
|
526
1023
|
|
|
527
|
-
node.bullConn.register(node);
|
|
528
1024
|
node.queueEvents = node.bullConn.createQueueEvents(node);
|
|
529
|
-
|
|
1025
|
+
if (!node.queueEvents) {
|
|
1026
|
+
// Construction failed synchronously and already reported why.
|
|
1027
|
+
setDisconnected(node);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const eventsStartupFailureOwner =
|
|
1031
|
+
node.bullConn.config.backend === "postgres" ? node.bullConn : undefined;
|
|
1032
|
+
const markQueueEventsReady = attachErrorListener(
|
|
1033
|
+
node.queueEvents,
|
|
1034
|
+
node,
|
|
1035
|
+
eventsStartupFailureOwner,
|
|
1036
|
+
);
|
|
530
1037
|
const events = parseEventFilter(n.events);
|
|
531
1038
|
for (const event of events) {
|
|
532
1039
|
node.queueEvents.on(event, (payload, eventId) => {
|
|
@@ -545,18 +1052,22 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
545
1052
|
try {
|
|
546
1053
|
setConnecting(node);
|
|
547
1054
|
await node.queueEvents.waitUntilReady();
|
|
1055
|
+
markQueueEventsReady();
|
|
548
1056
|
setConnected(node);
|
|
549
1057
|
} catch (err) {
|
|
550
1058
|
setDisconnected(node);
|
|
551
|
-
|
|
1059
|
+
if (eventsStartupFailureOwner) {
|
|
1060
|
+
eventsStartupFailureOwner.reportBackendFailure(err);
|
|
1061
|
+
} else {
|
|
1062
|
+
node.error(err);
|
|
1063
|
+
}
|
|
552
1064
|
}
|
|
553
1065
|
}
|
|
554
1066
|
updateReadyStatus();
|
|
555
1067
|
|
|
556
1068
|
node.on("close", async function onClose(removed, done) {
|
|
557
1069
|
try {
|
|
558
|
-
await
|
|
559
|
-
node.bullConn.deregister(node, () => {});
|
|
1070
|
+
await node.bullConn.releaseResource(node.queueEvents);
|
|
560
1071
|
done();
|
|
561
1072
|
} catch (err) {
|
|
562
1073
|
done(err);
|
|
@@ -572,21 +1083,36 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
572
1083
|
|
|
573
1084
|
if (!node.bullConn) {
|
|
574
1085
|
node.status({ fill: "red", shape: "ring", text: "missing queue" });
|
|
575
|
-
node.error("Missing
|
|
1086
|
+
node.error("Missing bullmq-queue-server config node");
|
|
576
1087
|
return;
|
|
577
1088
|
}
|
|
578
1089
|
|
|
579
|
-
node.bullConn.register(node);
|
|
580
1090
|
node.flowProducer = node.bullConn.createFlowProducer(node);
|
|
581
|
-
|
|
1091
|
+
if (!node.flowProducer) {
|
|
1092
|
+
// Construction failed synchronously and already reported why.
|
|
1093
|
+
setDisconnected(node);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const flowStartupFailureOwner =
|
|
1097
|
+
node.bullConn.config.backend === "postgres" ? node.bullConn : undefined;
|
|
1098
|
+
const markFlowProducerReady = attachErrorListener(
|
|
1099
|
+
node.flowProducer,
|
|
1100
|
+
node,
|
|
1101
|
+
flowStartupFailureOwner,
|
|
1102
|
+
);
|
|
582
1103
|
async function updateReadyStatus() {
|
|
583
1104
|
try {
|
|
584
1105
|
setConnecting(node);
|
|
585
1106
|
await node.flowProducer.waitUntilReady();
|
|
1107
|
+
markFlowProducerReady();
|
|
586
1108
|
setConnected(node);
|
|
587
1109
|
} catch (err) {
|
|
588
1110
|
setDisconnected(node);
|
|
589
|
-
|
|
1111
|
+
if (flowStartupFailureOwner) {
|
|
1112
|
+
flowStartupFailureOwner.reportBackendFailure(err);
|
|
1113
|
+
} else {
|
|
1114
|
+
node.error(err);
|
|
1115
|
+
}
|
|
590
1116
|
}
|
|
591
1117
|
}
|
|
592
1118
|
updateReadyStatus();
|
|
@@ -594,11 +1120,29 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
594
1120
|
node.on("input", async function onInput(msg, send, done) {
|
|
595
1121
|
try {
|
|
596
1122
|
if (!msg.payload || typeof msg.payload !== "object") {
|
|
597
|
-
throw new Error(
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
"bullmq flow requires msg.payload to contain a flow tree, or an array of flow trees"
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
const retention = node.bullConn.config.defaultJobOptions;
|
|
1128
|
+
if (Array.isArray(msg.payload)) {
|
|
1129
|
+
// addBulk creates every tree or none of them, which is the whole
|
|
1130
|
+
// reason to use it instead of one add() per tree.
|
|
1131
|
+
if (msg.payload.length === 0) {
|
|
1132
|
+
throw new Error("bullmq flow requires at least one flow tree");
|
|
1133
|
+
}
|
|
1134
|
+
const trees = await node.flowProducer.addBulk(
|
|
1135
|
+
msg.payload.map((flow) => withBulkFlowJobDefaults(flow, retention))
|
|
1136
|
+
);
|
|
1137
|
+
msg.payload = trees.map(serializeFlowJob);
|
|
1138
|
+
} else {
|
|
1139
|
+
msg.payload = serializeFlowJob(
|
|
1140
|
+
await node.flowProducer.add(
|
|
1141
|
+
msg.payload,
|
|
1142
|
+
withFlowJobDefaults(msg.payload, msg.flowopts, retention)
|
|
1143
|
+
)
|
|
1144
|
+
);
|
|
598
1145
|
}
|
|
599
|
-
msg.payload = serializeFlowJob(
|
|
600
|
-
await node.flowProducer.add(msg.payload, msg.flowopts)
|
|
601
|
-
);
|
|
602
1146
|
nodeSend(node, send, msg);
|
|
603
1147
|
nodeDone(node, done);
|
|
604
1148
|
} catch (err) {
|
|
@@ -608,8 +1152,7 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
608
1152
|
|
|
609
1153
|
node.on("close", async function onClose(removed, done) {
|
|
610
1154
|
try {
|
|
611
|
-
await
|
|
612
|
-
node.bullConn.deregister(node, () => {});
|
|
1155
|
+
await node.bullConn.releaseResource(node.flowProducer);
|
|
613
1156
|
done();
|
|
614
1157
|
} catch (err) {
|
|
615
1158
|
done(err);
|
|
@@ -617,9 +1160,9 @@ module.exports = function registerBullMQNodes(RED) {
|
|
|
617
1160
|
});
|
|
618
1161
|
}
|
|
619
1162
|
|
|
620
|
-
RED.nodes.registerType("
|
|
621
|
-
RED.nodes.registerType("
|
|
622
|
-
RED.nodes.registerType("
|
|
623
|
-
RED.nodes.registerType("
|
|
624
|
-
RED.nodes.registerType("
|
|
1163
|
+
RED.nodes.registerType("bullmq cmd", BullQueueCmdNode);
|
|
1164
|
+
RED.nodes.registerType("bullmq run", BullQueueRunNode);
|
|
1165
|
+
RED.nodes.registerType("bullmq job", BullJobNode);
|
|
1166
|
+
RED.nodes.registerType("bullmq events", BullEventsNode);
|
|
1167
|
+
RED.nodes.registerType("bullmq flow", BullFlowNode);
|
|
625
1168
|
};
|