bullmq 5.77.3 → 5.77.5

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.
@@ -23,6 +23,13 @@ const clusterOriginalBzpopmin = Symbol('bullmqClusterOriginalBzpopmin');
23
23
  const clusterWrappedBzpopmin = Symbol('bullmqClusterWrappedBzpopmin');
24
24
  const clusterPatchRefCount = Symbol('bullmqClusterPatchRefCount');
25
25
  const clusterClosingRefCount = Symbol('bullmqClusterClosingRefCount');
26
+ // Hard cap on a single cluster reconnect attempt. Without this, a hung
27
+ // `client.connect()` (e.g. ioredis Cluster cannot recover and slot refresh
28
+ // keeps retrying internally) leaves `clusterReconnectPromise` pinned forever
29
+ // and every subsequent bzpopmin call awaits the same dead promise, deadlocking
30
+ // the worker. 30s is comfortably above the default ioredis slotsRefreshTimeout
31
+ // while bounded enough that wedged workers recover within one or two ticks.
32
+ const DEFAULT_CLUSTER_RECONNECT_TIMEOUT_MS = 30000;
26
33
  class RedisConnection extends events_1.EventEmitter {
27
34
  constructor(opts, extraOptions) {
28
35
  super();
@@ -36,7 +43,7 @@ class RedisConnection extends events_1.EventEmitter {
36
43
  this.packageVersion = version_1.version;
37
44
  this.disabledBlockingClusterReconnect = false;
38
45
  // Set extra options defaults
39
- this.extraOptions = Object.assign({ shared: false, blocking: true, skipVersionCheck: false, skipWaitingForReady: false }, extraOptions);
46
+ this.extraOptions = Object.assign({ shared: false, blocking: true, skipVersionCheck: false, skipWaitingForReady: false, clusterReconnectTimeoutMs: DEFAULT_CLUSTER_RECONNECT_TIMEOUT_MS }, extraOptions);
40
47
  if (!(0, utils_2.isRedisInstance)(opts)) {
41
48
  this.checkBlockingOptions(overrideMessage, opts);
42
49
  this.opts = Object.assign({ port: 6379, host: '127.0.0.1', retryStrategy: function (times) {
@@ -210,6 +217,7 @@ class RedisConnection extends events_1.EventEmitter {
210
217
  return this._client;
211
218
  }
212
219
  patchBlockingClusterClient() {
220
+ var _a;
213
221
  const client = this._client;
214
222
  const blockingClient = client;
215
223
  if (!this.extraOptions.blocking ||
@@ -217,6 +225,7 @@ class RedisConnection extends events_1.EventEmitter {
217
225
  typeof blockingClient.bzpopmin !== 'function') {
218
226
  return;
219
227
  }
228
+ const reconnectTimeoutMs = (_a = this.extraOptions.clusterReconnectTimeoutMs) !== null && _a !== void 0 ? _a : DEFAULT_CLUSTER_RECONNECT_TIMEOUT_MS;
220
229
  blockingClient[clusterPatchRefCount] =
221
230
  (blockingClient[clusterPatchRefCount] || 0) + 1;
222
231
  this.patchedBlockingClusterClient = blockingClient;
@@ -225,7 +234,7 @@ class RedisConnection extends events_1.EventEmitter {
225
234
  }
226
235
  const bzpopmin = blockingClient.bzpopmin;
227
236
  const wrappedBzpopmin = async (...args) => {
228
- await RedisConnection.reconnectClusterIfNeeded(blockingClient);
237
+ await RedisConnection.reconnectClusterIfNeeded(blockingClient, reconnectTimeoutMs);
229
238
  try {
230
239
  return await bzpopmin.apply(blockingClient, args);
231
240
  }
@@ -233,7 +242,7 @@ class RedisConnection extends events_1.EventEmitter {
233
242
  const commandError = error;
234
243
  if (RedisConnection.shouldReconnectClusterAfterError(blockingClient, commandError)) {
235
244
  try {
236
- await RedisConnection.reconnectCluster(blockingClient);
245
+ await RedisConnection.reconnectCluster(blockingClient, reconnectTimeoutMs);
237
246
  }
238
247
  catch (_a) {
239
248
  // Preserve the original command failure if best-effort recovery fails.
@@ -298,10 +307,10 @@ class RedisConnection extends events_1.EventEmitter {
298
307
  client.status === 'end' ||
299
308
  client.status === 'closing');
300
309
  }
301
- static async reconnectClusterIfNeeded(client) {
310
+ static async reconnectClusterIfNeeded(client, timeoutMs) {
302
311
  if (!RedisConnection.isReconnectingDisabled(client) &&
303
312
  RedisConnection.isClusterWithEmptyNodes(client)) {
304
- await RedisConnection.reconnectCluster(client);
313
+ await RedisConnection.reconnectCluster(client, timeoutMs);
305
314
  }
306
315
  }
307
316
  static shouldReconnectClusterAfterError(client, error) {
@@ -317,20 +326,46 @@ class RedisConnection extends events_1.EventEmitter {
317
326
  return (RedisConnection.isClusterWithEmptyNodes(client) ||
318
327
  /Command timed out|Failed to refresh slots cache/i.test(message));
319
328
  }
320
- static async reconnectCluster(client) {
329
+ static async reconnectCluster(client, timeoutMs) {
321
330
  if (RedisConnection.isReconnectingDisabled(client)) {
322
331
  return;
323
332
  }
324
333
  if (!client[clusterReconnectPromise]) {
325
- client[clusterReconnectPromise] = (async () => {
326
- client.disconnect(false);
327
- await client.connect();
328
- })().finally(() => {
329
- client[clusterReconnectPromise] = null;
330
- });
334
+ client[clusterReconnectPromise] =
335
+ RedisConnection.connectClusterWithTimeout(client, timeoutMs).finally(() => {
336
+ client[clusterReconnectPromise] = null;
337
+ });
331
338
  }
332
339
  await client[clusterReconnectPromise];
333
340
  }
341
+ // Disconnects and reconnects a cluster client, racing connect() against a
342
+ // hard cap so a hung reconnect cannot pin `clusterReconnectPromise`
343
+ // indefinitely. On timeout, the underlying connect() is left running
344
+ // (ioredis Cluster handles its own retry/state); we simply stop awaiting it.
345
+ // The next bzpopmin call's `reconnectClusterIfNeeded` check will trigger a
346
+ // fresh reconnect if the pool is still empty.
347
+ static async connectClusterWithTimeout(client, timeoutMs) {
348
+ client.disconnect(false);
349
+ let timeoutHandle;
350
+ try {
351
+ await Promise.race([
352
+ client.connect(),
353
+ new Promise((_, reject) => {
354
+ var _a;
355
+ timeoutHandle = setTimeout(() => {
356
+ reject(new connection_closed_error_1.ConnectionClosedError(`BullMQ: cluster reconnect timed out after ${timeoutMs}ms`));
357
+ }, timeoutMs);
358
+ // Don't keep the event loop alive solely for this timer.
359
+ (_a = timeoutHandle.unref) === null || _a === void 0 ? void 0 : _a.call(timeoutHandle);
360
+ }),
361
+ ]);
362
+ }
363
+ finally {
364
+ if (timeoutHandle) {
365
+ clearTimeout(timeoutHandle);
366
+ }
367
+ }
368
+ }
334
369
  async disconnect(wait = true) {
335
370
  const client = await this.client;
336
371
  if (client.status !== 'end') {
@@ -17,9 +17,17 @@ local function requeueDeduplicatedJob(prefix, deduplicationId, eventStreamKey,
17
17
  local deduplicationNextKey = prefix .. "dn:" .. deduplicationId
18
18
  if rcall("EXISTS", deduplicationNextKey) == 1 then
19
19
  local nextData = rcall("HMGET", deduplicationNextKey,
20
- "name", "data", "opts", "pk", "pd", "pdk", "rjk")
20
+ "name", "data", "opts", "pk", "pd", "pdk", "rjk", "jid")
21
21
 
22
- local newJobId = rcall("INCR", prefix .. "id") .. ""
22
+ -- Always increment the counter to keep it monotonic
23
+ local nextId = rcall("INCR", prefix .. "id") .. ""
24
+ local storedJobId = nextData[8] -- index 8 = "jid" (8th field in the HMGET call above)
25
+ local newJobId
26
+ if storedJobId then
27
+ newJobId = storedJobId
28
+ else
29
+ newJobId = nextId
30
+ end
23
31
  local newJobIdKey = prefix .. newJobId
24
32
  local newOpts = cjson.decode(nextData[3])
25
33
  local deduplicationKey = prefix .. "de:" .. deduplicationId
@@ -14,7 +14,8 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
14
14
  local activeItems = rcall('LRANGE', activeKey, 0, -1)
15
15
  if checkItemInList(activeItems, currentDebounceJobId) then
16
16
  local deduplicationNextKey = prefix .. "dn:" .. deduplicationId
17
- local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts)}
17
+ local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts),
18
+ 'jid', jobId}
18
19
 
19
20
  if parentKey then
20
21
  fields[#fields+1] = 'pk'
@@ -36,6 +37,7 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
36
37
  fields[#fields+1] = repeatJobKey
37
38
  end
38
39
 
40
+ rcall('DEL', deduplicationNextKey)
39
41
  rcall('HSET', deduplicationNextKey, unpack(fields))
40
42
 
41
43
  -- Ensure the dedup key does not expire while the job is active,
@@ -155,7 +155,8 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
155
155
  local activeItems = rcall('LRANGE', activeKey, 0, -1)
156
156
  if checkItemInList(activeItems, currentDebounceJobId) then
157
157
  local deduplicationNextKey = prefix .. "dn:" .. deduplicationId
158
- local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts)}
158
+ local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts),
159
+ 'jid', jobId}
159
160
  if parentKey then
160
161
  fields[#fields+1] = 'pk'
161
162
  fields[#fields+1] = parentKey
@@ -172,6 +173,7 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
172
173
  fields[#fields+1] = 'rjk'
173
174
  fields[#fields+1] = repeatJobKey
174
175
  end
176
+ rcall('DEL', deduplicationNextKey)
175
177
  rcall('HSET', deduplicationNextKey, unpack(fields))
176
178
  -- Ensure the dedup key does not expire while the job is active,
177
179
  -- so subsequent adds always hit the dedup path and never bypass
@@ -86,7 +86,8 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
86
86
  local activeItems = rcall('LRANGE', activeKey, 0, -1)
87
87
  if checkItemInList(activeItems, currentDebounceJobId) then
88
88
  local deduplicationNextKey = prefix .. "dn:" .. deduplicationId
89
- local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts)}
89
+ local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts),
90
+ 'jid', jobId}
90
91
  if parentKey then
91
92
  fields[#fields+1] = 'pk'
92
93
  fields[#fields+1] = parentKey
@@ -103,6 +104,7 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
103
104
  fields[#fields+1] = 'rjk'
104
105
  fields[#fields+1] = repeatJobKey
105
106
  end
107
+ rcall('DEL', deduplicationNextKey)
106
108
  rcall('HSET', deduplicationNextKey, unpack(fields))
107
109
  -- Ensure the dedup key does not expire while the job is active,
108
110
  -- so subsequent adds always hit the dedup path and never bypass
@@ -120,7 +120,8 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
120
120
  local activeItems = rcall('LRANGE', activeKey, 0, -1)
121
121
  if checkItemInList(activeItems, currentDebounceJobId) then
122
122
  local deduplicationNextKey = prefix .. "dn:" .. deduplicationId
123
- local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts)}
123
+ local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts),
124
+ 'jid', jobId}
124
125
  if parentKey then
125
126
  fields[#fields+1] = 'pk'
126
127
  fields[#fields+1] = parentKey
@@ -137,6 +138,7 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
137
138
  fields[#fields+1] = 'rjk'
138
139
  fields[#fields+1] = repeatJobKey
139
140
  end
141
+ rcall('DEL', deduplicationNextKey)
140
142
  rcall('HSET', deduplicationNextKey, unpack(fields))
141
143
  -- Ensure the dedup key does not expire while the job is active,
142
144
  -- so subsequent adds always hit the dedup path and never bypass
@@ -114,7 +114,8 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
114
114
  local activeItems = rcall('LRANGE', activeKey, 0, -1)
115
115
  if checkItemInList(activeItems, currentDebounceJobId) then
116
116
  local deduplicationNextKey = prefix .. "dn:" .. deduplicationId
117
- local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts)}
117
+ local fields = {'name', jobName, 'data', jobData, 'opts', cjson.encode(fullOpts),
118
+ 'jid', jobId}
118
119
  if parentKey then
119
120
  fields[#fields+1] = 'pk'
120
121
  fields[#fields+1] = parentKey
@@ -131,6 +132,7 @@ local function storeDeduplicatedNextJob(deduplicationOpts, currentDebounceJobId,
131
132
  fields[#fields+1] = 'rjk'
132
133
  fields[#fields+1] = repeatJobKey
133
134
  end
135
+ rcall('DEL', deduplicationNextKey)
134
136
  rcall('HSET', deduplicationNextKey, unpack(fields))
135
137
  -- Ensure the dedup key does not expire while the job is active,
136
138
  -- so subsequent adds always hit the dedup path and never bypass
@@ -829,8 +829,16 @@ local function requeueDeduplicatedJob(prefix, deduplicationId, eventStreamKey,
829
829
  local deduplicationNextKey = prefix .. "dn:" .. deduplicationId
830
830
  if rcall("EXISTS", deduplicationNextKey) == 1 then
831
831
  local nextData = rcall("HMGET", deduplicationNextKey,
832
- "name", "data", "opts", "pk", "pd", "pdk", "rjk")
833
- local newJobId = rcall("INCR", prefix .. "id") .. ""
832
+ "name", "data", "opts", "pk", "pd", "pdk", "rjk", "jid")
833
+ -- Always increment the counter to keep it monotonic
834
+ local nextId = rcall("INCR", prefix .. "id") .. ""
835
+ local storedJobId = nextData[8] -- index 8 = "jid" (8th field in the HMGET call above)
836
+ local newJobId
837
+ if storedJobId then
838
+ newJobId = storedJobId
839
+ else
840
+ newJobId = nextId
841
+ end
834
842
  local newJobIdKey = prefix .. newJobId
835
843
  local newOpts = cjson.decode(nextData[3])
836
844
  local deduplicationKey = prefix .. "de:" .. deduplicationId