@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.
@@ -2,9 +2,16 @@
2
2
 
3
3
  const { isIP } = require("node:net");
4
4
 
5
- const DEFAULT_REDIS_HOST = "localhost";
5
+ // Shared by both backends; "localhost" is the right default either way.
6
+ const DEFAULT_HOST = "localhost";
6
7
  const DEFAULT_REDIS_PORT = 6379;
7
8
  const CLUSTER_PREFIX = "{bull}";
9
+ const DEFAULT_POSTGRES_PORT = 5432;
10
+ const DEFAULT_POSTGRES_POOL_MAX = 2;
11
+ // The only fail-fast lever on the PostgreSQL path (skipWaitingForReady is
12
+ // Redis-only) -- matches the Redis path's connectTimeout.
13
+ const POSTGRES_CONNECTION_TIMEOUT_MS = 10000;
14
+ const VALID_BACKENDS = ["redis", "postgres"];
8
15
 
9
16
  function isPresent(value) {
10
17
  return value !== undefined && value !== null && value !== "";
@@ -23,17 +30,44 @@ function toBoolean(value, defaultValue = false) {
23
30
  return ["true", "1", "yes", "on"].includes(String(value).toLowerCase());
24
31
  }
25
32
 
26
- function toPort(value, defaultValue = DEFAULT_REDIS_PORT) {
33
+ function toPort(value, defaultValue = DEFAULT_REDIS_PORT, label = "Redis") {
27
34
  if (!isPresent(value)) {
28
35
  return defaultValue;
29
36
  }
30
37
  const port = Number(value);
31
38
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
32
- throw new Error(`Invalid Redis port: ${value}`);
39
+ throw new Error(`Invalid ${label} port: ${value}`);
33
40
  }
34
41
  return port;
35
42
  }
36
43
 
44
+ // A saved 2.0.0 flow has no backend property at all, so absent/blank MUST
45
+ // mean "redis" -- that is the only way an existing flow keeps working
46
+ // untouched.
47
+ function normalizeBackend(config) {
48
+ if (!isPresent(config.backend)) {
49
+ return "redis";
50
+ }
51
+ const backend = String(config.backend).trim().toLowerCase();
52
+ if (!VALID_BACKENDS.includes(backend)) {
53
+ throw new Error(`Unsupported backend: ${config.backend}`);
54
+ }
55
+ return backend;
56
+ }
57
+
58
+ function toPoolMax(value) {
59
+ if (!isPresent(value)) {
60
+ return DEFAULT_POSTGRES_POOL_MAX;
61
+ }
62
+ const max = Number(value);
63
+ if (!Number.isInteger(max) || max < 1) {
64
+ throw new Error(
65
+ "PostgreSQL pool max must be a positive whole number of connections",
66
+ );
67
+ }
68
+ return max;
69
+ }
70
+
37
71
  function parseEndpoint(
38
72
  endpoint,
39
73
  defaultPort = DEFAULT_REDIS_PORT,
@@ -116,27 +150,87 @@ function parseEndpointList(
116
150
  .map((item) => parseEndpoint(item, defaultPort, tls, tlsName));
117
151
  }
118
152
 
119
- function readSecret(config, credentials, name) {
120
- if (credentials && isPresent(credentials[name])) {
121
- return credentials[name];
153
+ function readSecret(credentials, name) {
154
+ return credentials && isPresent(credentials[name])
155
+ ? credentials[name]
156
+ : undefined;
157
+ }
158
+
159
+ // Blank means "keep every job", which is BullMQ's own default and grows
160
+ // without bound. A count keeps the newest N and is what production needs.
161
+ function toKeepCount(value, name) {
162
+ if (!isPresent(value)) {
163
+ return undefined;
164
+ }
165
+ const count = Number(value);
166
+ if (!Number.isInteger(count) || count < 0) {
167
+ throw new Error(
168
+ `${name} must be a non-negative whole number of jobs to keep`,
169
+ );
170
+ }
171
+ return count;
172
+ }
173
+
174
+ function buildDefaultJobOptions(config) {
175
+ const defaults = {};
176
+ const removeOnComplete = toKeepCount(
177
+ config.removeOnComplete,
178
+ "removeOnComplete",
179
+ );
180
+ const removeOnFail = toKeepCount(config.removeOnFail, "removeOnFail");
181
+ if (removeOnComplete !== undefined) {
182
+ defaults.removeOnComplete = removeOnComplete;
122
183
  }
123
- return config[name];
184
+ if (removeOnFail !== undefined) {
185
+ defaults.removeOnFail = removeOnFail;
186
+ }
187
+ return Object.keys(defaults).length > 0 ? defaults : undefined;
124
188
  }
125
189
 
126
190
  function normalizeQueueConfig(config = {}, credentials = {}) {
127
- const deploymentInput = String(
128
- config.deployment || config.mode || config.redisMode || "single",
129
- ).toLowerCase();
130
- const deployment =
131
- deploymentInput === "memorydb" ? "cluster" : deploymentInput;
191
+ const removedAlias = [
192
+ "mode",
193
+ "redisMode",
194
+ "host",
195
+ "queueName",
196
+ "startupNodes",
197
+ "masterName",
198
+ "nameOfMaster",
199
+ ].find((name) => isPresent(config[name]));
200
+ if (removedAlias) {
201
+ throw new Error(`Unsupported config field: ${removedAlias}`);
202
+ }
203
+ const plaintextSecret = [
204
+ "password",
205
+ "sentinelPassword",
206
+ "tlsCa",
207
+ "tlsCert",
208
+ "tlsKey",
209
+ ].find((name) => isPresent(config[name]));
210
+ if (plaintextSecret) {
211
+ throw new Error(
212
+ `${plaintextSecret} must be stored in Node-RED credentials`,
213
+ );
214
+ }
215
+
216
+ // Validate only the fields belonging to the selected backend. Hidden
217
+ // editor rows keep their values, so a flow switched to postgres still
218
+ // carries deployment/clusterNodes/prefix in its saved JSON -- rejecting
219
+ // those would make switching backends impossible without hand-editing
220
+ // flows.
221
+ if (normalizeBackend(config) === "postgres") {
222
+ return normalizePostgresConfig(config, credentials);
223
+ }
224
+
225
+ const deployment = String(config.deployment || "single").toLowerCase();
132
226
 
133
227
  if (!["single", "cluster", "sentinel"].includes(deployment)) {
134
- throw new Error(`Unsupported Redis deployment mode: ${deploymentInput}`);
228
+ throw new Error(`Unsupported Redis deployment mode: ${deployment}`);
135
229
  }
136
230
 
137
- const host = config.host || config.address || DEFAULT_REDIS_HOST;
231
+ const host = config.address || DEFAULT_HOST;
138
232
  const port = toPort(config.port, DEFAULT_REDIS_PORT);
139
- const queueName = String(config.queueName || config.name || "").trim();
233
+ const queueName = String(config.name || "").trim();
140
234
  if (!queueName) {
141
235
  throw new Error("BullMQ queue name is required");
142
236
  }
@@ -150,22 +244,22 @@ function normalizeQueueConfig(config = {}, credentials = {}) {
150
244
  port,
151
245
  db: isPresent(config.db) ? Number(config.db) : undefined,
152
246
  username: config.username || undefined,
153
- password: readSecret(config, credentials, "password") || undefined,
247
+ password: readSecret(credentials, "password"),
154
248
  tls,
155
249
  tlsRejectUnauthorized: toBoolean(config.tlsRejectUnauthorized, true),
156
- tlsCa: readSecret(config, credentials, "tlsCa") || undefined,
157
- tlsCert: readSecret(config, credentials, "tlsCert") || undefined,
158
- tlsKey: readSecret(config, credentials, "tlsKey") || undefined,
250
+ tlsCa: readSecret(credentials, "tlsCa"),
251
+ tlsCert: readSecret(credentials, "tlsCert"),
252
+ tlsKey: readSecret(credentials, "tlsKey"),
159
253
  tlsServerName: config.tlsServerName || undefined,
160
254
  prefix: config.prefix || undefined,
255
+ defaultJobOptions: buildDefaultJobOptions(config),
161
256
  clusterNodes: parseEndpointList(
162
- config.clusterNodes || config.startupNodes,
257
+ config.clusterNodes,
163
258
  DEFAULT_REDIS_PORT,
164
259
  tls,
165
260
  "TLS",
166
261
  ),
167
- sentinelMasterName:
168
- config.sentinelMasterName || config.masterName || config.nameOfMaster,
262
+ sentinelMasterName: config.sentinelMasterName,
169
263
  sentinels: parseEndpointList(
170
264
  config.sentinels,
171
265
  26379,
@@ -173,9 +267,11 @@ function normalizeQueueConfig(config = {}, credentials = {}) {
173
267
  "Sentinel TLS",
174
268
  ),
175
269
  sentinelUsername: config.sentinelUsername || undefined,
176
- sentinelPassword:
177
- readSecret(config, credentials, "sentinelPassword") || undefined,
270
+ sentinelPassword: readSecret(credentials, "sentinelPassword"),
178
271
  sentinelTls,
272
+ telemetry: toBoolean(config.telemetry, false),
273
+ telemetryServiceName: config.telemetryServiceName || undefined,
274
+ telemetryMetrics: toBoolean(config.telemetryMetrics, false),
179
275
  };
180
276
 
181
277
  if (deployment === "cluster") {
@@ -229,8 +325,73 @@ function buildTlsOptions(config, enabled) {
229
325
  return tls;
230
326
  }
231
327
 
328
+ // Produces the pg pool config BullMQ's createPostgresBackend will receive as
329
+ // `connection`. Only the fields PostgreSQL needs are read; every Redis-only
330
+ // field a switched flow still carries in its saved JSON (deployment,
331
+ // clusterNodes, sentinels, prefix, ...) is simply never touched here, so it
332
+ // is ignored rather than rejected.
333
+ function normalizePostgresConfig(config, credentials) {
334
+ const queueName = String(config.name || "").trim();
335
+ if (!queueName) {
336
+ throw new Error("BullMQ queue name is required");
337
+ }
338
+ const tls = toBoolean(config.tls, false);
339
+ const ssl = buildTlsOptions(
340
+ {
341
+ tlsRejectUnauthorized: toBoolean(config.tlsRejectUnauthorized, true),
342
+ tlsServerName: config.tlsServerName || undefined,
343
+ tlsCa: readSecret(credentials, "tlsCa"),
344
+ tlsCert: readSecret(credentials, "tlsCert"),
345
+ tlsKey: readSecret(credentials, "tlsKey"),
346
+ },
347
+ tls,
348
+ );
349
+
350
+ const postgres = {
351
+ host: String(config.address || DEFAULT_HOST).trim(),
352
+ port: toPort(config.port, DEFAULT_POSTGRES_PORT, "PostgreSQL"),
353
+ database: config.database || undefined,
354
+ user: config.username || undefined,
355
+ password: readSecret(credentials, "password"),
356
+ schema: config.schema || undefined,
357
+ max: toPoolMax(config.max),
358
+ connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS,
359
+ migrate: toBoolean(config.migrate, true),
360
+ };
361
+ if (ssl) {
362
+ postgres.ssl = ssl;
363
+ }
364
+
365
+ return {
366
+ backend: "postgres",
367
+ queueName,
368
+ postgres,
369
+ defaultJobOptions: buildDefaultJobOptions(config),
370
+ telemetry: toBoolean(config.telemetry, false),
371
+ telemetryServiceName: config.telemetryServiceName || undefined,
372
+ telemetryMetrics: toBoolean(config.telemetryMetrics, false),
373
+ };
374
+ }
375
+
376
+ // BullMQ's production guidance: exponential reconnect backoff with a 1s floor
377
+ // and a 20s ceiling, rather than ioredis's default 50ms-2s, which hammers a
378
+ // down Redis once per connection role per runtime node.
379
+ const RECONNECT_FLOOR_MS = 1000;
380
+ const RECONNECT_CEILING_MS = 20000;
381
+
382
+ function reconnectBackoff(attempt) {
383
+ return Math.min(
384
+ RECONNECT_FLOOR_MS * 2 ** (attempt - 1),
385
+ RECONNECT_CEILING_MS,
386
+ );
387
+ }
388
+
389
+ function isConsumerRole(role) {
390
+ return role === "worker" || role === "events";
391
+ }
392
+
232
393
  function retryValueForRole(role) {
233
- return role === "worker" || role === "events" ? null : 1;
394
+ return isConsumerRole(role) ? null : 1;
234
395
  }
235
396
 
236
397
  function buildStandaloneOptions(config, role) {
@@ -240,8 +401,16 @@ function buildStandaloneOptions(config, role) {
240
401
  maxRetriesPerRequest: retryValueForRole(role),
241
402
  enableReadyCheck: true,
242
403
  connectTimeout: 10000,
404
+ retryStrategy: reconnectBackoff,
243
405
  };
244
406
 
407
+ // Deliberately NOT enableOfflineQueue:false, which BullMQ's production guide
408
+ // suggests for producers. Measured against a healthy Redis, it rejects a
409
+ // command issued during the connect window -- which in Node-RED is every
410
+ // deploy-time message. Keeping the offline queue accepts those, while
411
+ // maxRetriesPerRequest above still fails a genuinely-down Redis in about a
412
+ // second instead of hanging.
413
+
245
414
  if (config.db !== undefined && !Number.isNaN(config.db)) {
246
415
  options.db = config.db;
247
416
  }
@@ -265,6 +434,7 @@ function buildRedisDescriptor(config, role = "producer") {
265
434
  maxRetriesPerRequest: retryValueForRole(role),
266
435
  enableReadyCheck: true,
267
436
  connectTimeout: 10000,
437
+ retryStrategy: reconnectBackoff,
268
438
  };
269
439
  if (config.username) {
270
440
  redisOptions.username = config.username;
@@ -285,6 +455,7 @@ function buildRedisDescriptor(config, role = "producer") {
285
455
  slotsRefreshTimeout: 2000,
286
456
  maxRedirections: 16,
287
457
  dnsLookup: (address, callback) => callback(null, address),
458
+ clusterRetryStrategy: reconnectBackoff,
288
459
  },
289
460
  };
290
461
  }
@@ -298,6 +469,7 @@ function buildRedisDescriptor(config, role = "producer") {
298
469
  }
299
470
  options.sentinels = config.sentinels;
300
471
  options.name = config.sentinelMasterName;
472
+ options.sentinelRetryStrategy = reconnectBackoff;
301
473
  if (config.sentinelUsername) {
302
474
  options.sentinelUsername = config.sentinelUsername;
303
475
  }
@@ -322,7 +494,7 @@ function createRedisConnection(descriptor, IORedis) {
322
494
  return new IORedis(descriptor.options);
323
495
  }
324
496
 
325
- function buildBullMQOptions(config, connection) {
497
+ function buildBullMQOptions(config, connection, telemetry, role = "producer") {
326
498
  const options = {};
327
499
  if (connection) {
328
500
  options.connection = connection;
@@ -330,13 +502,27 @@ function buildBullMQOptions(config, connection) {
330
502
  if (config.prefix) {
331
503
  options.prefix = config.prefix;
332
504
  }
505
+ if (telemetry) {
506
+ options.telemetry = telemetry;
507
+ }
508
+ // Without this, BullMQ awaits a connection-ready promise that never settles
509
+ // while Redis is unreachable, so a producer command hangs forever instead of
510
+ // erroring. Consumers must keep waiting for the connection to come back.
511
+ // Only createRedisBackend forwards skipWaitingForReady (verified in
512
+ // node_modules/bullmq/dist/cjs/utils/create-backend.js) -- createPostgresBackend
513
+ // never reads it, so setting it there would be a misleading no-op.
514
+ if (!isConsumerRole(role) && config.backend !== "postgres") {
515
+ options.skipWaitingForReady = true;
516
+ }
333
517
  return options;
334
518
  }
335
519
 
336
520
  module.exports = {
521
+ POSTGRES_CONNECTION_TIMEOUT_MS,
337
522
  buildBullMQOptions,
338
523
  buildRedisDescriptor,
339
524
  createRedisConnection,
340
525
  normalizeQueueConfig,
526
+ normalizePostgresConfig,
341
527
  parseEndpointList,
342
528
  };
package/lib/scheduler.js CHANGED
@@ -4,78 +4,8 @@ function hasOwn(object, key) {
4
4
  return Object.prototype.hasOwnProperty.call(object, key);
5
5
  }
6
6
 
7
- function clonePlain(value) {
8
- if (!value || typeof value !== "object" || Array.isArray(value)) {
9
- return value;
10
- }
11
- return { ...value };
12
- }
13
-
14
- function normalizeRepeatOptions(repeat) {
15
- const normalized = clonePlain(repeat) || {};
16
- if (
17
- normalized.cron &&
18
- normalized.pattern &&
19
- normalized.cron !== normalized.pattern
20
- ) {
21
- throw new Error(
22
- "repeat.cron and repeat.pattern must match when both are supplied",
23
- );
24
- }
25
- if (normalized.cron) {
26
- normalized.pattern = normalized.cron;
27
- delete normalized.cron;
28
- }
29
- return normalized;
30
- }
31
-
32
- function getJobData(msg) {
33
- if (hasOwn(msg, "jobData")) {
34
- return msg.jobData;
35
- }
36
- return { payload: msg.payload };
37
- }
38
-
39
- function normalizeAddRequest(msg = {}) {
40
- const jobopts = clonePlain(msg.jobopts) || {};
41
- const name = msg.jobName || "default";
42
-
43
- if (jobopts.repeat) {
44
- const schedulerId = String(msg.schedulerId || jobopts.jobId || "").trim();
45
- if (!schedulerId) {
46
- throw new Error(
47
- "scheduled jobs require msg.schedulerId or msg.jobopts.jobId",
48
- );
49
- }
50
-
51
- const repeat = normalizeRepeatOptions(jobopts.repeat);
52
- delete jobopts.repeat;
53
- delete jobopts.jobId;
54
-
55
- return {
56
- kind: "scheduler",
57
- schedulerId,
58
- repeat,
59
- template: {
60
- name,
61
- data: getJobData(msg),
62
- opts: jobopts,
63
- },
64
- };
65
- }
66
-
67
- return {
68
- kind: "job",
69
- name,
70
- data: getJobData(msg),
71
- opts: jobopts,
72
- };
73
- }
74
-
75
- function getLegacySchedulerId(msg = {}) {
76
- const schedulerId = String(
77
- msg.schedulerId || msg.jobid || msg.jobId || "",
78
- ).trim();
7
+ function getSchedulerId(msg = {}) {
8
+ const schedulerId = String(msg.schedulerId || "").trim();
79
9
  if (!schedulerId) {
80
10
  throw new Error("A scheduler id is required");
81
11
  }
@@ -99,6 +29,8 @@ function serializeScheduler(scheduler) {
99
29
  "offset",
100
30
  "tz",
101
31
  "endDate",
32
+ "startDate",
33
+ "iterationCount",
102
34
  ]) {
103
35
  if (hasOwn(scheduler, key)) {
104
36
  result[key] = scheduler[key];
@@ -108,7 +40,6 @@ function serializeScheduler(scheduler) {
108
40
  }
109
41
 
110
42
  module.exports = {
111
- getLegacySchedulerId,
112
- normalizeAddRequest,
43
+ getSchedulerId,
113
44
  serializeScheduler,
114
45
  };
@@ -1,9 +1,5 @@
1
1
  "use strict";
2
2
 
3
- function hasOwn(object, key) {
4
- return Object.prototype.hasOwnProperty.call(object, key);
5
- }
6
-
7
3
  function copyKnownFields(source, fields) {
8
4
  if (!source) {
9
5
  return source;
@@ -31,6 +27,7 @@ function serializeJob(job) {
31
27
  "opts",
32
28
  "progress",
33
29
  "attemptsMade",
30
+ "attemptsStarted",
34
31
  "failedReason",
35
32
  "stacktrace",
36
33
  "returnvalue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pauldeng/node-red-contrib-bullmq",
3
- "version": "1.0.3",
4
- "description": "BullMQ-backed Redis job queue nodes for Node-RED",
3
+ "version": "2.0.0",
4
+ "description": "BullMQ job queue nodes for Node-RED, backed by Redis or PostgreSQL",
5
5
  "main": "bull-queue.js",
6
6
  "files": [
7
7
  "bull-queue.js",
@@ -12,7 +12,7 @@
12
12
  "docs/*.md"
13
13
  ],
14
14
  "engines": {
15
- "node": ">=18"
15
+ "node": ">=22.9"
16
16
  },
17
17
  "scripts": {
18
18
  "test": "node --test test/*.test.js",
@@ -25,9 +25,21 @@
25
25
  "validate": "npx --yes node-red-dev validate"
26
26
  },
27
27
  "dependencies": {
28
- "bullmq": "5.80.9",
28
+ "bullmq": "6.3.1",
29
29
  "ioredis": "5.11.1"
30
30
  },
31
+ "peerDependencies": {
32
+ "bullmq-otel": ">=2.0.0",
33
+ "pg": ">=8.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "bullmq-otel": {
37
+ "optional": true
38
+ },
39
+ "pg": {
40
+ "optional": true
41
+ }
42
+ },
31
43
  "license": "MIT",
32
44
  "keywords": [
33
45
  "node-red",
@@ -36,11 +48,13 @@
36
48
  "queue",
37
49
  "bull",
38
50
  "redis",
51
+ "postgres",
52
+ "postgresql",
39
53
  "worker",
40
54
  "jobs"
41
55
  ],
42
56
  "node-red": {
43
- "version": ">=4.1.0 <6",
57
+ "version": ">=5.0.0 <6",
44
58
  "nodes": {
45
59
  "bull-queue": "bull-queue.js"
46
60
  }
@@ -62,9 +76,11 @@
62
76
  "email": "dengpeng.cn@gmail.com"
63
77
  },
64
78
  "devDependencies": {
65
- "@playwright/test": "1.61.1",
66
- "node-red": "5.0.1",
79
+ "@playwright/test": "1.62.1",
80
+ "bullmq-otel": "2.0.1",
81
+ "node-red": "5.0.4",
67
82
  "node-red-node-test-helper": "^0.3.6",
68
- "prettier": "3.9.4"
83
+ "pg": "8.23.0",
84
+ "prettier": "3.9.6"
69
85
  }
70
86
  }