@pauldeng/node-red-contrib-bullmq 1.0.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -42
- package/bull-queue.html +300 -91
- package/bull-queue.js +686 -143
- package/docs/ARCHITECTURE.md +63 -17
- package/docs/CHANGE_WORKFLOW.md +10 -11
- package/docs/COMMANDS.md +72 -11
- package/docs/CONNECTIONS.md +74 -7
- package/docs/MIGRATION.md +56 -22
- package/docs/NODE_GUIDE.md +52 -14
- package/docs/REFERENCE_MAP.md +29 -10
- package/docs/RELEASE.md +18 -5
- package/docs/RULES.md +33 -0
- package/docs/TELEMETRY.md +55 -0
- package/docs/TESTING.md +30 -16
- package/docs/TROUBLESHOOTING.md +44 -5
- package/examples/README.md +40 -12
- package/examples/bullmq_features.json +218 -16
- package/examples/example_flow.json +11 -11
- package/examples/postgres_backend.json +149 -0
- package/examples/repeatable_jobs.json +51 -19
- package/examples/scheduled_notifications.json +216 -0
- package/lib/acknowledgements.js +19 -1
- package/lib/commands.js +74 -35
- package/lib/connections.js +271 -48
- package/lib/scheduler.js +5 -74
- package/lib/serialization.js +1 -4
- package/package.json +25 -9
package/lib/connections.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const { isIP } = require("node:net");
|
|
4
|
+
|
|
5
|
+
// Shared by both backends; "localhost" is the right default either way.
|
|
6
|
+
const DEFAULT_HOST = "localhost";
|
|
4
7
|
const DEFAULT_REDIS_PORT = 6379;
|
|
5
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"];
|
|
6
15
|
|
|
7
16
|
function isPresent(value) {
|
|
8
17
|
return value !== undefined && value !== null && value !== "";
|
|
@@ -21,18 +30,50 @@ function toBoolean(value, defaultValue = false) {
|
|
|
21
30
|
return ["true", "1", "yes", "on"].includes(String(value).toLowerCase());
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
function toPort(value, defaultValue = DEFAULT_REDIS_PORT) {
|
|
33
|
+
function toPort(value, defaultValue = DEFAULT_REDIS_PORT, label = "Redis") {
|
|
25
34
|
if (!isPresent(value)) {
|
|
26
35
|
return defaultValue;
|
|
27
36
|
}
|
|
28
37
|
const port = Number(value);
|
|
29
38
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
30
|
-
throw new Error(`Invalid
|
|
39
|
+
throw new Error(`Invalid ${label} port: ${value}`);
|
|
31
40
|
}
|
|
32
41
|
return port;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
|
-
|
|
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
|
+
|
|
71
|
+
function parseEndpoint(
|
|
72
|
+
endpoint,
|
|
73
|
+
defaultPort = DEFAULT_REDIS_PORT,
|
|
74
|
+
tls,
|
|
75
|
+
tlsName = "TLS",
|
|
76
|
+
) {
|
|
36
77
|
if (typeof endpoint === "object" && endpoint !== null) {
|
|
37
78
|
const host = endpoint.host || endpoint.address;
|
|
38
79
|
if (!isPresent(host)) {
|
|
@@ -48,36 +89,54 @@ function parseEndpoint(endpoint, defaultPort = DEFAULT_REDIS_PORT) {
|
|
|
48
89
|
if (!text) {
|
|
49
90
|
throw new Error("Redis endpoint cannot be empty");
|
|
50
91
|
}
|
|
92
|
+
if (isIP(text)) {
|
|
93
|
+
return { host: text, port: defaultPort };
|
|
94
|
+
}
|
|
51
95
|
|
|
52
|
-
|
|
53
|
-
let
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
const hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(text);
|
|
97
|
+
let url;
|
|
98
|
+
try {
|
|
99
|
+
url = new URL(hasScheme ? text : `redis://${text}`);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error(`Invalid Redis endpoint: ${text}`);
|
|
102
|
+
}
|
|
103
|
+
if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
|
|
104
|
+
throw new Error(`Unsupported Redis endpoint protocol: ${url.protocol}`);
|
|
105
|
+
}
|
|
106
|
+
if (url.username || url.password) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"Redis endpoint URLs cannot include credentials; use the config node credential fields",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (hasScheme && tls !== undefined) {
|
|
112
|
+
const secure = url.protocol === "rediss:";
|
|
113
|
+
if (secure !== tls) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`${url.protocol}// endpoint requires ${tlsName} to be ${secure ? "enabled" : "disabled"}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
64
118
|
}
|
|
65
119
|
|
|
66
|
-
|
|
120
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
121
|
+
if (!host) {
|
|
67
122
|
throw new Error(`Redis endpoint requires a host: ${text}`);
|
|
68
123
|
}
|
|
69
|
-
|
|
70
|
-
return { host: host.trim(), port };
|
|
124
|
+
return { host, port: toPort(url.port, defaultPort) };
|
|
71
125
|
}
|
|
72
126
|
|
|
73
|
-
function parseEndpointList(
|
|
127
|
+
function parseEndpointList(
|
|
128
|
+
value,
|
|
129
|
+
defaultPort = DEFAULT_REDIS_PORT,
|
|
130
|
+
tls,
|
|
131
|
+
tlsName = "TLS",
|
|
132
|
+
) {
|
|
74
133
|
if (Array.isArray(value)) {
|
|
75
134
|
return value
|
|
76
135
|
.flatMap((item) =>
|
|
77
136
|
typeof item === "string" ? item.split(/[\n,]+/) : [item],
|
|
78
137
|
)
|
|
79
138
|
.filter((item) => isPresent(item))
|
|
80
|
-
.map((item) => parseEndpoint(item, defaultPort));
|
|
139
|
+
.map((item) => parseEndpoint(item, defaultPort, tls, tlsName));
|
|
81
140
|
}
|
|
82
141
|
|
|
83
142
|
if (!isPresent(value)) {
|
|
@@ -88,33 +147,95 @@ function parseEndpointList(value, defaultPort = DEFAULT_REDIS_PORT) {
|
|
|
88
147
|
.split(/[\n,]+/)
|
|
89
148
|
.map((item) => item.trim())
|
|
90
149
|
.filter(Boolean)
|
|
91
|
-
.map((item) => parseEndpoint(item, defaultPort));
|
|
150
|
+
.map((item) => parseEndpoint(item, defaultPort, tls, tlsName));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readSecret(credentials, name) {
|
|
154
|
+
return credentials && isPresent(credentials[name])
|
|
155
|
+
? credentials[name]
|
|
156
|
+
: undefined;
|
|
92
157
|
}
|
|
93
158
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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;
|
|
97
164
|
}
|
|
98
|
-
|
|
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;
|
|
183
|
+
}
|
|
184
|
+
if (removeOnFail !== undefined) {
|
|
185
|
+
defaults.removeOnFail = removeOnFail;
|
|
186
|
+
}
|
|
187
|
+
return Object.keys(defaults).length > 0 ? defaults : undefined;
|
|
99
188
|
}
|
|
100
189
|
|
|
101
190
|
function normalizeQueueConfig(config = {}, credentials = {}) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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();
|
|
107
226
|
|
|
108
227
|
if (!["single", "cluster", "sentinel"].includes(deployment)) {
|
|
109
|
-
throw new Error(`Unsupported Redis deployment mode: ${
|
|
228
|
+
throw new Error(`Unsupported Redis deployment mode: ${deployment}`);
|
|
110
229
|
}
|
|
111
230
|
|
|
112
|
-
const host = config.
|
|
231
|
+
const host = config.address || DEFAULT_HOST;
|
|
113
232
|
const port = toPort(config.port, DEFAULT_REDIS_PORT);
|
|
114
|
-
const queueName = String(config.
|
|
233
|
+
const queueName = String(config.name || "").trim();
|
|
115
234
|
if (!queueName) {
|
|
116
235
|
throw new Error("BullMQ queue name is required");
|
|
117
236
|
}
|
|
237
|
+
const tls = toBoolean(config.tls, false);
|
|
238
|
+
const sentinelTls = toBoolean(config.sentinelTls, false);
|
|
118
239
|
|
|
119
240
|
const normalized = {
|
|
120
241
|
queueName,
|
|
@@ -123,22 +244,34 @@ function normalizeQueueConfig(config = {}, credentials = {}) {
|
|
|
123
244
|
port,
|
|
124
245
|
db: isPresent(config.db) ? Number(config.db) : undefined,
|
|
125
246
|
username: config.username || undefined,
|
|
126
|
-
password: readSecret(
|
|
127
|
-
tls
|
|
247
|
+
password: readSecret(credentials, "password"),
|
|
248
|
+
tls,
|
|
128
249
|
tlsRejectUnauthorized: toBoolean(config.tlsRejectUnauthorized, true),
|
|
129
|
-
tlsCa: readSecret(
|
|
130
|
-
tlsCert: readSecret(
|
|
131
|
-
tlsKey: readSecret(
|
|
250
|
+
tlsCa: readSecret(credentials, "tlsCa"),
|
|
251
|
+
tlsCert: readSecret(credentials, "tlsCert"),
|
|
252
|
+
tlsKey: readSecret(credentials, "tlsKey"),
|
|
132
253
|
tlsServerName: config.tlsServerName || undefined,
|
|
133
254
|
prefix: config.prefix || undefined,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
config.
|
|
137
|
-
|
|
255
|
+
defaultJobOptions: buildDefaultJobOptions(config),
|
|
256
|
+
clusterNodes: parseEndpointList(
|
|
257
|
+
config.clusterNodes,
|
|
258
|
+
DEFAULT_REDIS_PORT,
|
|
259
|
+
tls,
|
|
260
|
+
"TLS",
|
|
261
|
+
),
|
|
262
|
+
sentinelMasterName: config.sentinelMasterName,
|
|
263
|
+
sentinels: parseEndpointList(
|
|
264
|
+
config.sentinels,
|
|
265
|
+
26379,
|
|
266
|
+
sentinelTls,
|
|
267
|
+
"Sentinel TLS",
|
|
268
|
+
),
|
|
138
269
|
sentinelUsername: config.sentinelUsername || undefined,
|
|
139
|
-
sentinelPassword:
|
|
140
|
-
|
|
141
|
-
|
|
270
|
+
sentinelPassword: readSecret(credentials, "sentinelPassword"),
|
|
271
|
+
sentinelTls,
|
|
272
|
+
telemetry: toBoolean(config.telemetry, false),
|
|
273
|
+
telemetryServiceName: config.telemetryServiceName || undefined,
|
|
274
|
+
telemetryMetrics: toBoolean(config.telemetryMetrics, false),
|
|
142
275
|
};
|
|
143
276
|
|
|
144
277
|
if (deployment === "cluster") {
|
|
@@ -192,8 +325,73 @@ function buildTlsOptions(config, enabled) {
|
|
|
192
325
|
return tls;
|
|
193
326
|
}
|
|
194
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
|
+
|
|
195
393
|
function retryValueForRole(role) {
|
|
196
|
-
return role
|
|
394
|
+
return isConsumerRole(role) ? null : 1;
|
|
197
395
|
}
|
|
198
396
|
|
|
199
397
|
function buildStandaloneOptions(config, role) {
|
|
@@ -203,8 +401,16 @@ function buildStandaloneOptions(config, role) {
|
|
|
203
401
|
maxRetriesPerRequest: retryValueForRole(role),
|
|
204
402
|
enableReadyCheck: true,
|
|
205
403
|
connectTimeout: 10000,
|
|
404
|
+
retryStrategy: reconnectBackoff,
|
|
206
405
|
};
|
|
207
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
|
+
|
|
208
414
|
if (config.db !== undefined && !Number.isNaN(config.db)) {
|
|
209
415
|
options.db = config.db;
|
|
210
416
|
}
|
|
@@ -228,6 +434,7 @@ function buildRedisDescriptor(config, role = "producer") {
|
|
|
228
434
|
maxRetriesPerRequest: retryValueForRole(role),
|
|
229
435
|
enableReadyCheck: true,
|
|
230
436
|
connectTimeout: 10000,
|
|
437
|
+
retryStrategy: reconnectBackoff,
|
|
231
438
|
};
|
|
232
439
|
if (config.username) {
|
|
233
440
|
redisOptions.username = config.username;
|
|
@@ -248,6 +455,7 @@ function buildRedisDescriptor(config, role = "producer") {
|
|
|
248
455
|
slotsRefreshTimeout: 2000,
|
|
249
456
|
maxRedirections: 16,
|
|
250
457
|
dnsLookup: (address, callback) => callback(null, address),
|
|
458
|
+
clusterRetryStrategy: reconnectBackoff,
|
|
251
459
|
},
|
|
252
460
|
};
|
|
253
461
|
}
|
|
@@ -261,6 +469,7 @@ function buildRedisDescriptor(config, role = "producer") {
|
|
|
261
469
|
}
|
|
262
470
|
options.sentinels = config.sentinels;
|
|
263
471
|
options.name = config.sentinelMasterName;
|
|
472
|
+
options.sentinelRetryStrategy = reconnectBackoff;
|
|
264
473
|
if (config.sentinelUsername) {
|
|
265
474
|
options.sentinelUsername = config.sentinelUsername;
|
|
266
475
|
}
|
|
@@ -285,7 +494,7 @@ function createRedisConnection(descriptor, IORedis) {
|
|
|
285
494
|
return new IORedis(descriptor.options);
|
|
286
495
|
}
|
|
287
496
|
|
|
288
|
-
function buildBullMQOptions(config, connection) {
|
|
497
|
+
function buildBullMQOptions(config, connection, telemetry, role = "producer") {
|
|
289
498
|
const options = {};
|
|
290
499
|
if (connection) {
|
|
291
500
|
options.connection = connection;
|
|
@@ -293,13 +502,27 @@ function buildBullMQOptions(config, connection) {
|
|
|
293
502
|
if (config.prefix) {
|
|
294
503
|
options.prefix = config.prefix;
|
|
295
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
|
+
}
|
|
296
517
|
return options;
|
|
297
518
|
}
|
|
298
519
|
|
|
299
520
|
module.exports = {
|
|
521
|
+
POSTGRES_CONNECTION_TIMEOUT_MS,
|
|
300
522
|
buildBullMQOptions,
|
|
301
523
|
buildRedisDescriptor,
|
|
302
524
|
createRedisConnection,
|
|
303
525
|
normalizeQueueConfig,
|
|
526
|
+
normalizePostgresConfig,
|
|
304
527
|
parseEndpointList,
|
|
305
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
|
|
8
|
-
|
|
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
|
-
|
|
112
|
-
normalizeAddRequest,
|
|
43
|
+
getSchedulerId,
|
|
113
44
|
serializeScheduler,
|
|
114
45
|
};
|
package/lib/serialization.js
CHANGED
|
@@ -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": "
|
|
4
|
-
"description": "BullMQ
|
|
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": ">=
|
|
15
|
+
"node": ">=22.9"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"test": "node --test test/*.test.js",
|
|
@@ -25,8 +25,20 @@
|
|
|
25
25
|
"validate": "npx --yes node-red-dev validate"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"bullmq": "
|
|
29
|
-
"ioredis": "5.
|
|
28
|
+
"bullmq": "6.3.1",
|
|
29
|
+
"ioredis": "5.11.1"
|
|
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
|
+
}
|
|
30
42
|
},
|
|
31
43
|
"license": "MIT",
|
|
32
44
|
"keywords": [
|
|
@@ -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": ">=
|
|
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.
|
|
66
|
-
"
|
|
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
|
-
"
|
|
83
|
+
"pg": "8.23.0",
|
|
84
|
+
"prettier": "3.9.6"
|
|
69
85
|
}
|
|
70
86
|
}
|