@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/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
- async function settleWithin(promise, ms) {
51
- let settled = false;
52
- (async () => {
53
- try {
54
- await promise;
55
- } catch (err) {
56
- // a failed graceful close still counts as settled
57
- }
58
- settled = true;
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
- const deadline = Date.now() + ms;
62
- while (!settled && Date.now() < deadline) {
63
- await sleep(25);
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 resource. Its public disconnect() awaits a connection promise that
88
- // never settles while Redis is unreachable, so reach for the underlying
89
- // ioredis clients directly (BullMQ is pinned to exactly 5.80.2).
90
- if (resource.connection) {
91
- disconnectClient(resource.connection._client);
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
- if (resource.blockingConnection) {
94
- disconnectClient(resource.blockingConnection._client);
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 the
119
- // sockets when it does not settle in time.
120
- if ((await settleWithin(resource.close(), CLOSE_GRACE_MS)) === "timeout") {
121
- forceDisconnect(resource);
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
- function parsePositiveInteger(value, defaultValue) {
138
- if (value === undefined || value === null || value === "") {
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(`Expected a positive integer, got ${value}`);
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
- node.error(err);
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.users = {};
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.register = function register(bullNode) {
212
- node.users[bullNode.id] = bullNode;
213
- bullNode.status({
214
- fill: "grey",
215
- shape: "ring",
216
- text: "configured",
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
- node.deregister = function deregister(bullNode, done) {
221
- delete node.users[bullNode.id];
222
- done();
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
- node.producerConnection = node.createConnection("producer");
239
- node.queue = new Queue(
240
- node.config.queueName,
241
- buildBullMQOptions(node.config, node.producerConnection)
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
- // The producer connection backs the shared queue used by bull cmd nodes;
249
- // exposing it lets those nodes mirror the real connection state.
250
- node.getProducerConnection = function getProducerConnection() {
251
- node.getQueue();
252
- return node.producerConnection;
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
- const connection = node.createConnection("worker", owner);
260
- const worker = new Worker(node.config.queueName, processor, {
261
- ...buildBullMQOptions(node.config, connection),
262
- ...options,
263
- });
264
- node.resources.add(worker);
265
- return worker;
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
- const connection = node.createConnection("events", owner);
270
- const queueEvents = new QueueEvents(
271
- node.config.queueName,
272
- buildBullMQOptions(node.config, connection)
273
- );
274
- node.resources.add(queueEvents);
275
- return queueEvents;
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
- const connection = node.createConnection("producer", owner);
280
- const flowProducer = new FlowProducer(
281
- buildBullMQOptions(node.config, connection)
282
- );
283
- node.resources.add(flowProducer);
284
- return flowProducer;
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
- if (node.queue) {
290
- await closeResource(node.queue);
291
- }
292
- const resources = Array.from(node.resources).reverse();
293
- for (const resource of resources) {
294
- await closeResource(resource);
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("bull-queue-server", BullQueueServerSetup, {
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 bull-queue-server config node");
683
+ node.error("Missing bullmq-queue-server config node");
323
684
  return;
324
685
  }
325
686
 
326
- node.bullConn.register(node);
327
-
328
- // Watch the shared producer connection so the visible status reflects
329
- // whether Redis is actually reachable instead of a static "configured".
330
- const connection = node.bullConn.getProducerConnection();
331
- const connectionListeners = {
332
- ready: () => setConnected(node),
333
- error: () => setDisconnected(node),
334
- close: () => setDisconnected(node),
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
- for (const [event, listener] of Object.entries(connectionListeners)) {
337
- connection.on(event, listener);
338
- }
339
- if (connection.status === "ready") {
340
- setConnected(node);
341
- } else {
342
- setConnecting(node);
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 result = await dispatchCommand(node.bullConn.getQueue(), msg);
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
- for (const [event, listener] of Object.entries(connectionListeners)) {
358
- connection.removeListener(event, listener);
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 bull-queue-server config node");
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
- if (n.limiterMax && n.limiterDuration) {
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(n.limiterDuration),
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
- const processor = async (job) => {
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
- attachErrorListener(node.worker, node);
417
- node.worker.on("ready", () => setConnected(node));
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 closeResource(node.worker);
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 bull job action: ${action}`);
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 bull-queue-server config node");
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
- attachErrorListener(node.queueEvents, node);
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
- node.error(err);
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 closeResource(node.queueEvents);
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 bull-queue-server config node");
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
- attachErrorListener(node.flowProducer, node);
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
- node.error(err);
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("bull flow requires msg.payload to contain a flow tree");
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 closeResource(node.flowProducer);
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("bull cmd", BullQueueCmdNode);
621
- RED.nodes.registerType("bull run", BullQueueRunNode);
622
- RED.nodes.registerType("bull job", BullJobNode);
623
- RED.nodes.registerType("bull events", BullEventsNode);
624
- RED.nodes.registerType("bull flow", BullFlowNode);
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
  };