@pauldeng/node-red-contrib-bullmq 1.0.3 → 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,6 +58,34 @@ const DEFAULT_EVENTS = [
47
58
  // an unreachable Redis server.
48
59
  const CLOSE_GRACE_MS = 1000;
49
60
 
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;
78
+
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
+
50
89
  async function settled(promise) {
51
90
  try {
52
91
  await promise;
@@ -56,13 +95,16 @@ async function settled(promise) {
56
95
  return "settled";
57
96
  }
58
97
 
59
- async function timedOut(ms) {
60
- await sleep(ms);
61
- return "timeout";
62
- }
63
-
64
98
  async function settleWithin(promise, ms) {
65
- return await Promise.race([settled(promise), timedOut(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();
107
+ }
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.9).
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
+ );
92
144
  }
93
- if (resource.blockingConnection) {
94
- disconnectClient(resource.blockingConnection._client);
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();
95
153
  }
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();
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,26 +174,29 @@ 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);
122
193
  }
123
194
  }
124
195
 
125
196
  async function closeResourcePair(owner, connection) {
126
197
  let firstError;
127
198
  try {
128
- await closeResource(owner);
199
+ await closeResource(owner, connection);
129
200
  } catch (err) {
130
201
  firstError = err;
131
202
  }
@@ -151,6 +222,62 @@ function nodeDone(node, done, err, msg) {
151
222
  }
152
223
  }
153
224
 
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
+
154
281
  function isPresent(value) {
155
282
  return value !== undefined && value !== null && value !== "";
156
283
  }
@@ -190,14 +317,60 @@ function setDisconnected(node) {
190
317
  node.status({ fill: "red", shape: "ring", text: "disconnected" });
191
318
  }
192
319
 
193
- function attachErrorListener(resource, node) {
320
+ function attachErrorListener(resource, node, startupFailureOwner) {
194
321
  if (!resource || typeof resource.on !== "function") {
195
- return;
322
+ return () => {};
196
323
  }
324
+ let ready = false;
197
325
  resource.on("error", (err) => {
198
326
  setDisconnected(node);
199
- node.error(err);
327
+ if (startupFailureOwner && !ready) {
328
+ startupFailureOwner.reportBackendFailure(err);
329
+ } else {
330
+ node.error(err);
331
+ }
200
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
+ };
201
374
  }
202
375
 
203
376
  function createJobMessage(job, queueName, extraBull = {}) {
@@ -223,24 +396,60 @@ module.exports = function registerBullMQNodes(RED) {
223
396
  RED.nodes.createNode(this, n);
224
397
  const node = this;
225
398
 
226
- node.users = {};
227
399
  node.resources = new Map();
228
400
  node.config = normalizeQueueConfig(n, node.credentials || {});
229
401
  node.queue = null;
230
402
  node.producerConnection = null;
231
-
232
- node.register = function register(bullNode) {
233
- node.users[bullNode.id] = bullNode;
234
- bullNode.status({
235
- fill: "grey",
236
- shape: "ring",
237
- text: "configured",
238
- });
403
+ node.telemetry = undefined;
404
+ node.telemetryUnavailable = false;
405
+
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);
239
419
  };
240
420
 
241
- node.deregister = function deregister(bullNode, done) {
242
- delete node.users[bullNode.id];
243
- 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;
244
453
  };
245
454
 
246
455
  // owner is the node whose status should reflect connection errors. It
@@ -262,61 +471,182 @@ module.exports = function registerBullMQNodes(RED) {
262
471
  await closeResourcePair(owner, connection);
263
472
  };
264
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
+
265
525
  node.getQueue = function getQueue() {
266
526
  if (!node.queue) {
267
- node.producerConnection = node.createConnection("producer");
268
- node.producerConnection.setMaxListeners(0);
269
- node.queue = new Queue(
270
- node.config.queueName,
271
- 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,
272
539
  );
273
- node.resources.set(node.queue, node.producerConnection);
540
+ if (!resource) {
541
+ return null;
542
+ }
543
+ node.queue = resource;
544
+ node.producerConnection = connection || null;
545
+ node.watchBackend(node.queue.getBackend());
274
546
  attachErrorListener(node.queue, node);
275
547
  }
276
548
  return node.queue;
277
549
  };
278
550
 
279
- // The producer connection backs the shared queue used by bull cmd nodes;
280
- // exposing it lets those nodes mirror the real connection state.
281
- node.getProducerConnection = function getProducerConnection() {
282
- node.getQueue();
283
- 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
+ };
284
611
  };
285
612
 
286
613
  // Runtime nodes pass themselves as owner and attach their own resource
287
614
  // error listener, so worker/events/flow errors surface on the visible
288
615
  // runtime node rather than the hidden config node.
289
616
  node.createWorker = function createWorker(processor, options, owner = node) {
290
- const connection = node.createConnection("worker", owner);
291
- const worker = new Worker(node.config.queueName, processor, {
292
- ...buildBullMQOptions(node.config, connection),
293
- ...options,
294
- });
295
- node.resources.set(worker, connection);
296
- 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;
297
625
  };
298
626
 
299
627
  node.createQueueEvents = function createQueueEvents(owner = node) {
300
- const connection = node.createConnection("events", owner);
301
- const queueEvents = new QueueEvents(
302
- node.config.queueName,
303
- buildBullMQOptions(node.config, connection)
304
- );
305
- node.resources.set(queueEvents, connection);
306
- 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;
307
636
  };
308
637
 
309
638
  node.createFlowProducer = function createFlowProducer(owner = node) {
310
- const connection = node.createConnection("producer", owner);
311
- const flowProducer = new FlowProducer(
312
- buildBullMQOptions(node.config, connection)
313
- );
314
- node.resources.set(flowProducer, connection);
315
- return flowProducer;
639
+ return createResource(
640
+ "producer",
641
+ owner,
642
+ node.getTelemetry(),
643
+ (options, factory) => new FlowProducer(options, factory),
644
+ ).resource;
316
645
  };
317
646
 
318
647
  node.on("close", async function onClose(removed, done) {
319
648
  try {
649
+ node.backendFailures.clear();
320
650
  const resources = Array.from(node.resources.entries()).reverse();
321
651
  node.resources.clear();
322
652
  await Promise.all(
@@ -332,7 +662,7 @@ module.exports = function registerBullMQNodes(RED) {
332
662
  });
333
663
  }
334
664
 
335
- RED.nodes.registerType("bull-queue-server", BullQueueServerSetup, {
665
+ RED.nodes.registerType("bullmq-queue-server", BullQueueServerSetup, {
336
666
  credentials: {
337
667
  password: { type: "password" },
338
668
  sentinelPassword: { type: "password" },
@@ -350,32 +680,44 @@ module.exports = function registerBullMQNodes(RED) {
350
680
 
351
681
  if (!node.bullConn) {
352
682
  node.status({ fill: "red", shape: "ring", text: "missing queue" });
353
- node.error("Missing bull-queue-server config node");
683
+ node.error("Missing bullmq-queue-server config node");
354
684
  return;
355
685
  }
356
686
 
357
- node.bullConn.register(node);
358
-
359
- // Watch the shared producer connection so the visible status reflects
360
- // whether Redis is actually reachable instead of a static "configured".
361
- const connection = node.bullConn.getProducerConnection();
362
- const connectionListeners = {
363
- ready: () => setConnected(node),
364
- error: () => setDisconnected(node),
365
- 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
+ }
366
700
  };
367
- for (const [event, listener] of Object.entries(connectionListeners)) {
368
- connection.on(event, listener);
369
- }
370
- if (connection.status === "ready") {
371
- setConnected(node);
372
- } else {
373
- setConnecting(node);
374
- }
375
-
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);
376
709
  node.on("input", async function onInput(msg, send, done) {
377
710
  try {
378
- 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);
379
721
  msg.payload = result;
380
722
  nodeSend(node, send, msg);
381
723
  nodeDone(node, done);
@@ -385,10 +727,8 @@ module.exports = function registerBullMQNodes(RED) {
385
727
  });
386
728
 
387
729
  node.on("close", function onClose(removed, done) {
388
- for (const [event, listener] of Object.entries(connectionListeners)) {
389
- connection.removeListener(event, listener);
390
- }
391
- node.bullConn.deregister(node, done);
730
+ stopReadingBackend();
731
+ done();
392
732
  });
393
733
  }
394
734
 
@@ -401,14 +741,17 @@ module.exports = function registerBullMQNodes(RED) {
401
741
 
402
742
  if (!node.bullQueue) {
403
743
  node.status({ fill: "red", shape: "ring", text: "missing queue" });
404
- node.error("Missing bull-queue-server config node");
744
+ node.error("Missing bullmq-queue-server config node");
405
745
  return;
406
746
  }
407
747
 
408
- node.bullQueue.register(node);
409
-
410
748
  const workerOptions = {
411
749
  concurrency: parsePositiveInteger(n.concurrency, 1, "Concurrency"),
750
+ maxStartedAttempts: parsePositiveInteger(
751
+ n.maxStartedAttempts,
752
+ 100,
753
+ "Max Started Attempts"
754
+ ),
412
755
  };
413
756
  const hasLimiterMax = isPresent(n.limiterMax);
414
757
  const hasLimiterDuration = isPresent(n.limiterDuration);
@@ -425,8 +768,21 @@ module.exports = function registerBullMQNodes(RED) {
425
768
  ),
426
769
  };
427
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
+ }
428
778
 
429
- 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) => {
430
786
  if (node.completionMode === "manual") {
431
787
  const timeoutMs = parseAckTimeoutMs(n.ackTimeout);
432
788
  const acknowledgement = acknowledgements.create(
@@ -435,6 +791,9 @@ module.exports = function registerBullMQNodes(RED) {
435
791
  queue: node.bullQueue.getQueue(),
436
792
  queueName: node.bullQueue.config.queueName,
437
793
  runNodeId: node.id,
794
+ worker: node.worker,
795
+ signal,
796
+ token,
438
797
  },
439
798
  timeoutMs
440
799
  );
@@ -453,8 +812,52 @@ module.exports = function registerBullMQNodes(RED) {
453
812
  };
454
813
 
455
814
  node.worker = node.bullQueue.createWorker(processor, workerOptions, node);
456
- attachErrorListener(node.worker, node);
457
- 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
+ }
458
861
  node.worker.on("closed", () => setDisconnected(node));
459
862
  setConnecting(node);
460
863
 
@@ -465,7 +868,6 @@ module.exports = function registerBullMQNodes(RED) {
465
868
  );
466
869
  try {
467
870
  await node.bullQueue.releaseResource(node.worker);
468
- node.bullQueue.deregister(node, () => {});
469
871
  done();
470
872
  } catch (err) {
471
873
  done(err);
@@ -543,8 +945,63 @@ module.exports = function registerBullMQNodes(RED) {
543
945
  context.fail(Worker.RateLimitError());
544
946
  nodeDone(node, done);
545
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
+ }
546
1003
  default:
547
- throw new Error(`Unsupported bull job action: ${action}`);
1004
+ throw new Error(`Unsupported bullmq job action: ${action}`);
548
1005
  }
549
1006
  } catch (err) {
550
1007
  nodeDone(node, done, err, msg);
@@ -560,13 +1017,23 @@ module.exports = function registerBullMQNodes(RED) {
560
1017
 
561
1018
  if (!node.bullConn) {
562
1019
  node.status({ fill: "red", shape: "ring", text: "missing queue" });
563
- node.error("Missing bull-queue-server config node");
1020
+ node.error("Missing bullmq-queue-server config node");
564
1021
  return;
565
1022
  }
566
1023
 
567
- node.bullConn.register(node);
568
1024
  node.queueEvents = node.bullConn.createQueueEvents(node);
569
- 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
+ );
570
1037
  const events = parseEventFilter(n.events);
571
1038
  for (const event of events) {
572
1039
  node.queueEvents.on(event, (payload, eventId) => {
@@ -585,10 +1052,15 @@ module.exports = function registerBullMQNodes(RED) {
585
1052
  try {
586
1053
  setConnecting(node);
587
1054
  await node.queueEvents.waitUntilReady();
1055
+ markQueueEventsReady();
588
1056
  setConnected(node);
589
1057
  } catch (err) {
590
1058
  setDisconnected(node);
591
- node.error(err);
1059
+ if (eventsStartupFailureOwner) {
1060
+ eventsStartupFailureOwner.reportBackendFailure(err);
1061
+ } else {
1062
+ node.error(err);
1063
+ }
592
1064
  }
593
1065
  }
594
1066
  updateReadyStatus();
@@ -596,7 +1068,6 @@ module.exports = function registerBullMQNodes(RED) {
596
1068
  node.on("close", async function onClose(removed, done) {
597
1069
  try {
598
1070
  await node.bullConn.releaseResource(node.queueEvents);
599
- node.bullConn.deregister(node, () => {});
600
1071
  done();
601
1072
  } catch (err) {
602
1073
  done(err);
@@ -612,21 +1083,36 @@ module.exports = function registerBullMQNodes(RED) {
612
1083
 
613
1084
  if (!node.bullConn) {
614
1085
  node.status({ fill: "red", shape: "ring", text: "missing queue" });
615
- node.error("Missing bull-queue-server config node");
1086
+ node.error("Missing bullmq-queue-server config node");
616
1087
  return;
617
1088
  }
618
1089
 
619
- node.bullConn.register(node);
620
1090
  node.flowProducer = node.bullConn.createFlowProducer(node);
621
- 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
+ );
622
1103
  async function updateReadyStatus() {
623
1104
  try {
624
1105
  setConnecting(node);
625
1106
  await node.flowProducer.waitUntilReady();
1107
+ markFlowProducerReady();
626
1108
  setConnected(node);
627
1109
  } catch (err) {
628
1110
  setDisconnected(node);
629
- node.error(err);
1111
+ if (flowStartupFailureOwner) {
1112
+ flowStartupFailureOwner.reportBackendFailure(err);
1113
+ } else {
1114
+ node.error(err);
1115
+ }
630
1116
  }
631
1117
  }
632
1118
  updateReadyStatus();
@@ -634,11 +1120,29 @@ module.exports = function registerBullMQNodes(RED) {
634
1120
  node.on("input", async function onInput(msg, send, done) {
635
1121
  try {
636
1122
  if (!msg.payload || typeof msg.payload !== "object") {
637
- 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
+ );
638
1145
  }
639
- msg.payload = serializeFlowJob(
640
- await node.flowProducer.add(msg.payload, msg.flowopts)
641
- );
642
1146
  nodeSend(node, send, msg);
643
1147
  nodeDone(node, done);
644
1148
  } catch (err) {
@@ -649,7 +1153,6 @@ module.exports = function registerBullMQNodes(RED) {
649
1153
  node.on("close", async function onClose(removed, done) {
650
1154
  try {
651
1155
  await node.bullConn.releaseResource(node.flowProducer);
652
- node.bullConn.deregister(node, () => {});
653
1156
  done();
654
1157
  } catch (err) {
655
1158
  done(err);
@@ -657,9 +1160,9 @@ module.exports = function registerBullMQNodes(RED) {
657
1160
  });
658
1161
  }
659
1162
 
660
- RED.nodes.registerType("bull cmd", BullQueueCmdNode);
661
- RED.nodes.registerType("bull run", BullQueueRunNode);
662
- RED.nodes.registerType("bull job", BullJobNode);
663
- RED.nodes.registerType("bull events", BullEventsNode);
664
- 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);
665
1168
  };