@pauldeng/node-red-contrib-bullmq 1.0.0 → 1.0.1

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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/bull-queue.js +128 -26
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Node-RED nodes for BullMQ-backed Redis job queues.
10
10
 
11
- This package targets BullMQ 5.78.0, Node-RED 4.1, and Node.js 18 or newer. It preserves the legacy `bull-queue-server`, `bull cmd`, and `bull run` node types where BullMQ has compatible behavior, and adds `bull job`, `bull events`, and `bull flow`.
11
+ This package targets BullMQ 5.78.0 and Node-RED 4.1 or 5.x. It preserves the legacy `bull-queue-server`, `bull cmd`, and `bull run` node types where BullMQ has compatible behavior, and adds `bull job`, `bull events`, and `bull flow`.
12
12
 
13
13
  ## Installation
14
14
 
@@ -20,8 +20,8 @@ Repository: <https://github.com/pauldeng/node-red-contrib-bullmq>
20
20
 
21
21
  ## Requirements
22
22
 
23
- - Node.js 18+
24
- - Node-RED 4.1.x
23
+ - Node-RED 4.1.x or 5.x
24
+ - Node.js 18+ with Node-RED 4.1.x, or Node.js 22.9+ with Node-RED 5.x
25
25
  - Redis with `maxmemory-policy=noeviction`
26
26
  - BullMQ 5.78.0
27
27
 
package/bull-queue.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  Worker,
9
9
  } = require("bullmq");
10
10
  const IORedis = require("ioredis");
11
+ const { setTimeout: sleep } = require("node:timers/promises");
11
12
 
12
13
  const {
13
14
  AcknowledgementRegistry,
@@ -41,20 +42,83 @@ const DEFAULT_EVENTS = [
41
42
  "waiting-children",
42
43
  ];
43
44
 
44
- async function closeResource(resource) {
45
+ // How long a graceful close may take before the underlying sockets are
46
+ // force-disconnected so Node-RED shutdown and redeploy are never blocked by
47
+ // an unreachable Redis server.
48
+ const CLOSE_GRACE_MS = 1000;
49
+
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
+ })();
60
+
61
+ const deadline = Date.now() + ms;
62
+ while (!settled && Date.now() < deadline) {
63
+ await sleep(25);
64
+ }
65
+ return settled ? "settled" : "timeout";
66
+ }
67
+
68
+ function disconnectClient(client) {
69
+ if (client && typeof client.disconnect === "function") {
70
+ try {
71
+ client.disconnect(false);
72
+ } catch (err) {
73
+ // best effort: the socket may already be gone
74
+ }
75
+ }
76
+ }
77
+
78
+ function forceDisconnect(resource) {
45
79
  if (!resource) {
46
80
  return;
47
81
  }
48
- if (typeof resource.close === "function") {
49
- await resource.close();
82
+ if (typeof resource.status === "string") {
83
+ // Raw ioredis connection.
84
+ disconnectClient(resource);
50
85
  return;
51
86
  }
52
- if (typeof resource.quit === "function") {
53
- await resource.quit();
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.78.0).
90
+ if (resource.connection) {
91
+ disconnectClient(resource.connection._client);
92
+ }
93
+ if (resource.blockingConnection) {
94
+ disconnectClient(resource.blockingConnection._client);
95
+ }
96
+ }
97
+
98
+ async function closeResource(resource) {
99
+ if (!resource) {
54
100
  return;
55
101
  }
56
- if (typeof resource.disconnect === "function") {
57
- resource.disconnect();
102
+
103
+ if (typeof resource.close !== "function") {
104
+ // Raw ioredis connection: quit() never settles (or leaves a reconnect
105
+ // loop running) while the server is unreachable, so only quit ready
106
+ // connections and force-disconnect everything else.
107
+ if (resource.status === "ready" && typeof resource.quit === "function") {
108
+ if ((await settleWithin(resource.quit(), CLOSE_GRACE_MS)) === "timeout") {
109
+ forceDisconnect(resource);
110
+ }
111
+ } else {
112
+ forceDisconnect(resource);
113
+ }
114
+ return;
115
+ }
116
+
117
+ // 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);
58
122
  }
59
123
  }
60
124
 
@@ -91,12 +155,26 @@ function parseEventFilter(value) {
91
155
  .filter(Boolean);
92
156
  }
93
157
 
94
- function attachErrorListener(resource, node, label) {
158
+ // One status vocabulary for every queue-backed node: connecting (yellow),
159
+ // connected (green), disconnected (red).
160
+ function setConnecting(node) {
161
+ node.status({ fill: "yellow", shape: "ring", text: "connecting" });
162
+ }
163
+
164
+ function setConnected(node) {
165
+ node.status({ fill: "green", shape: "dot", text: "connected" });
166
+ }
167
+
168
+ function setDisconnected(node) {
169
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
170
+ }
171
+
172
+ function attachErrorListener(resource, node) {
95
173
  if (!resource || typeof resource.on !== "function") {
96
174
  return;
97
175
  }
98
176
  resource.on("error", (err) => {
99
- node.status({ fill: "red", shape: "ring", text: `${label}: error` });
177
+ setDisconnected(node);
100
178
  node.error(err);
101
179
  });
102
180
  }
@@ -150,7 +228,7 @@ module.exports = function registerBullMQNodes(RED) {
150
228
  node.createConnection = function createConnection(role, owner = node) {
151
229
  const descriptor = buildRedisDescriptor(node.config, role);
152
230
  const connection = createRedisConnection(descriptor, IORedis);
153
- attachErrorListener(connection, owner, `Redis ${role}`);
231
+ attachErrorListener(connection, owner);
154
232
  node.resources.add(connection);
155
233
  return connection;
156
234
  };
@@ -162,11 +240,18 @@ module.exports = function registerBullMQNodes(RED) {
162
240
  node.config.queueName,
163
241
  buildBullMQOptions(node.config, node.producerConnection)
164
242
  );
165
- attachErrorListener(node.queue, node, "BullMQ queue");
243
+ attachErrorListener(node.queue, node);
166
244
  }
167
245
  return node.queue;
168
246
  };
169
247
 
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;
253
+ };
254
+
170
255
  // Runtime nodes pass themselves as owner and attach their own resource
171
256
  // error listener, so worker/events/flow errors surface on the visible
172
257
  // runtime node rather than the hidden config node.
@@ -239,7 +324,23 @@ module.exports = function registerBullMQNodes(RED) {
239
324
  }
240
325
 
241
326
  node.bullConn.register(node);
242
- node.status({ fill: "green", shape: "dot", text: "configured" });
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),
335
+ };
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
+ }
243
344
 
244
345
  node.on("input", async function onInput(msg, send, done) {
245
346
  try {
@@ -253,6 +354,9 @@ module.exports = function registerBullMQNodes(RED) {
253
354
  });
254
355
 
255
356
  node.on("close", function onClose(removed, done) {
357
+ for (const [event, listener] of Object.entries(connectionListeners)) {
358
+ connection.removeListener(event, listener);
359
+ }
256
360
  node.bullConn.deregister(node, done);
257
361
  });
258
362
  }
@@ -309,14 +413,10 @@ module.exports = function registerBullMQNodes(RED) {
309
413
  };
310
414
 
311
415
  node.worker = node.bullQueue.createWorker(processor, workerOptions, node);
312
- attachErrorListener(node.worker, node, "BullMQ worker");
313
- node.worker.on("ready", () =>
314
- node.status({ fill: "green", shape: "dot", text: "connected" })
315
- );
316
- node.worker.on("closed", () =>
317
- node.status({ fill: "red", shape: "ring", text: "closed" })
318
- );
319
- node.status({ fill: "yellow", shape: "ring", text: "connecting" });
416
+ attachErrorListener(node.worker, node);
417
+ node.worker.on("ready", () => setConnected(node));
418
+ node.worker.on("closed", () => setDisconnected(node));
419
+ setConnecting(node);
320
420
 
321
421
  node.on("close", async function onClose(removed, done) {
322
422
  acknowledgements.rejectByRunNode(
@@ -426,7 +526,7 @@ module.exports = function registerBullMQNodes(RED) {
426
526
 
427
527
  node.bullConn.register(node);
428
528
  node.queueEvents = node.bullConn.createQueueEvents(node);
429
- attachErrorListener(node.queueEvents, node, "BullMQ events");
529
+ attachErrorListener(node.queueEvents, node);
430
530
  const events = parseEventFilter(n.events);
431
531
  for (const event of events) {
432
532
  node.queueEvents.on(event, (payload, eventId) => {
@@ -443,9 +543,11 @@ module.exports = function registerBullMQNodes(RED) {
443
543
  }
444
544
  async function updateReadyStatus() {
445
545
  try {
546
+ setConnecting(node);
446
547
  await node.queueEvents.waitUntilReady();
447
- node.status({ fill: "green", shape: "dot", text: "connected" });
548
+ setConnected(node);
448
549
  } catch (err) {
550
+ setDisconnected(node);
449
551
  node.error(err);
450
552
  }
451
553
  }
@@ -476,14 +578,14 @@ module.exports = function registerBullMQNodes(RED) {
476
578
 
477
579
  node.bullConn.register(node);
478
580
  node.flowProducer = node.bullConn.createFlowProducer(node);
479
- attachErrorListener(node.flowProducer, node, "BullMQ flow");
581
+ attachErrorListener(node.flowProducer, node);
480
582
  async function updateReadyStatus() {
481
583
  try {
482
- node.status({ fill: "yellow", shape: "ring", text: "connecting" });
584
+ setConnecting(node);
483
585
  await node.flowProducer.waitUntilReady();
484
- node.status({ fill: "green", shape: "dot", text: "connected" });
586
+ setConnected(node);
485
587
  } catch (err) {
486
- node.status({ fill: "red", shape: "ring", text: "connection error" });
588
+ setDisconnected(node);
487
589
  node.error(err);
488
590
  }
489
591
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pauldeng/node-red-contrib-bullmq",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "BullMQ-backed Redis job queue nodes for Node-RED",
5
5
  "main": "bull-queue.js",
6
6
  "files": [
@@ -40,7 +40,7 @@
40
40
  "jobs"
41
41
  ],
42
42
  "node-red": {
43
- "version": ">=4.1.0 <5",
43
+ "version": ">=4.1.0 <6",
44
44
  "nodes": {
45
45
  "bull-queue": "bull-queue.js"
46
46
  }
@@ -63,7 +63,7 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@playwright/test": "1.60.0",
66
- "node-red": "4.1.11",
66
+ "node-red": "5.0.0",
67
67
  "node-red-node-test-helper": "^0.3.6",
68
68
  "prettier": "3.8.3"
69
69
  }