bullmq 6.3.2 → 6.3.3

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.
@@ -46,15 +46,21 @@ export function createBunRedisClient(client, opts) {
46
46
  */
47
47
  class BunRedisAdapter extends EventEmitter {
48
48
  get status() {
49
+ var _a;
49
50
  if (this.statusOverride) {
50
51
  return this.statusOverride;
51
52
  }
52
53
  if (this.closed) {
53
54
  return 'end';
54
55
  }
55
- if (this.raw.connected) {
56
+ // `raw` may not exist yet on a duplicate whose raw client is created
57
+ // lazily (via `rawFactory`) on first connect.
58
+ if (this.ready) {
56
59
  return 'ready';
57
60
  }
61
+ if ((_a = this.raw) === null || _a === void 0 ? void 0 : _a.connected) {
62
+ return 'connect';
63
+ }
58
64
  return this.hasConnected ? 'end' : 'wait';
59
65
  }
60
66
  set status(val) {
@@ -87,8 +93,14 @@ class BunRedisAdapter extends EventEmitter {
87
93
  this.reconnectTimer = null;
88
94
  this.reconnectAttempts = 0;
89
95
  this.maxReconnectDelay = 20000; // cap at 20s (matches ioredis default)
96
+ this.ready = false;
90
97
  this.isCluster = false;
91
- this._setupCallbacks();
98
+ this.rawFactory = opts === null || opts === void 0 ? void 0 : opts.rawFactory;
99
+ // When a `rawFactory` is provided the raw client is created lazily on the
100
+ // first `connect()`; callbacks are wired up there once it exists.
101
+ if (this.raw) {
102
+ this._setupCallbacks();
103
+ }
92
104
  // ioredis auto-connects by default. Mimic that behavior unless
93
105
  // lazyConnect is set.
94
106
  if (!(opts === null || opts === void 0 ? void 0 : opts.lazyConnect)) {
@@ -106,23 +118,10 @@ class BunRedisAdapter extends EventEmitter {
106
118
  // event until CLIENT SETNAME completes so callers waiting for 'ready'
107
119
  // see the name already applied.
108
120
  this.raw.onconnect = () => {
109
- this.hasConnected = true;
110
- this.closed = false;
111
- this.closing = false;
112
- this.reconnecting = false;
113
- this.reconnectAttempts = 0;
114
- this.statusOverride = undefined;
115
- // The server-side SCRIPT cache is gone for this (possibly new) raw
116
- // connection. Force re-loading on next use.
117
- this.loadedScriptShas.clear();
118
- if (this.connectionName) {
119
- this.clientSetName(this.connectionName).then(() => this.emit('ready'), () => this.emit('ready'));
120
- }
121
- else {
122
- this.emit('ready');
123
- }
121
+ this._handleConnected();
124
122
  };
125
123
  this.raw.onclose = (error) => {
124
+ this.ready = false;
126
125
  if (this.closing) {
127
126
  // User-initiated close – no reconnect
128
127
  this.closed = true;
@@ -139,6 +138,31 @@ class BunRedisAdapter extends EventEmitter {
139
138
  this._scheduleReconnect();
140
139
  };
141
140
  }
141
+ _handleConnected() {
142
+ this.hasConnected = true;
143
+ this.ready = false;
144
+ this.closed = false;
145
+ this.closing = false;
146
+ this.reconnecting = false;
147
+ this.reconnectAttempts = 0;
148
+ this.statusOverride = undefined;
149
+ // The server-side SCRIPT cache is gone for this (possibly new) raw
150
+ // connection. Force re-loading on next use.
151
+ this.loadedScriptShas.clear();
152
+ const markReady = () => {
153
+ this.ready = true;
154
+ this.emit('ready');
155
+ };
156
+ const readying = this.connectionName
157
+ ? this.clientSetName(this.connectionName).then(markReady, markReady)
158
+ : (markReady(), Promise.resolve());
159
+ this.readying = readying.finally(() => {
160
+ if (this.readying === readying) {
161
+ this.readying = undefined;
162
+ }
163
+ });
164
+ return this.readying;
165
+ }
142
166
  /**
143
167
  * Schedule a reconnection attempt with exponential backoff.
144
168
  */
@@ -157,10 +181,16 @@ class BunRedisAdapter extends EventEmitter {
157
181
  return;
158
182
  }
159
183
  try {
160
- // Create a fresh raw client with the same URL
161
- const BunRedisClient = this.raw
162
- .constructor;
163
- const newRaw = new BunRedisClient(this.raw.url);
184
+ // Create a fresh raw client aimed at the *same* server. Bun's native
185
+ // `duplicate()` preserves the connection target and options; the old
186
+ // `new BunRedisClient(this.raw.url)` produced a client pointed at Bun's
187
+ // default target because `url` is always undefined (#4582). If the raw
188
+ // client was never created (a duplicate reconnecting before its first
189
+ // connect), fall back to its lazy factory.
190
+ const newRaw = this.raw
191
+ ? await this._duplicateRaw(this.raw)
192
+ : await this.rawFactory();
193
+ this.rawFactory = undefined;
164
194
  // Swap the raw client reference
165
195
  this.raw = newRaw;
166
196
  this.closed = false;
@@ -183,6 +213,14 @@ class BunRedisAdapter extends EventEmitter {
183
213
  // Connection lifecycle
184
214
  // ---------------------------------------------------------------
185
215
  async connect() {
216
+ var _a, _b;
217
+ // A duplicate created with a `rawFactory` builds its raw client lazily on
218
+ // the first connect (Bun's native `duplicate()` is async).
219
+ if (!this.raw && this.rawFactory) {
220
+ this.raw = await this.rawFactory();
221
+ this.rawFactory = undefined;
222
+ this._setupCallbacks();
223
+ }
186
224
  const replaceRaw = this.hasConnected && (this.closed || !this.raw.connected);
187
225
  if (this.reconnectTimer) {
188
226
  clearTimeout(this.reconnectTimer);
@@ -194,6 +232,9 @@ class BunRedisAdapter extends EventEmitter {
194
232
  this.closed = false;
195
233
  this.closing = false;
196
234
  this.statusOverride = undefined;
235
+ if (!this.ready) {
236
+ await ((_a = this.readying) !== null && _a !== void 0 ? _a : this._handleConnected());
237
+ }
197
238
  return;
198
239
  }
199
240
  if (!this.connecting) {
@@ -201,11 +242,10 @@ class BunRedisAdapter extends EventEmitter {
201
242
  this.closing = false;
202
243
  this.statusOverride = undefined;
203
244
  // If the raw client was previously closed, Bun doesn't support
204
- // reconnecting on the same instance. Create a fresh raw client.
245
+ // reconnecting on the same instance. Create a fresh raw client aimed at
246
+ // the same server via Bun's native `duplicate()` (see #4582).
205
247
  if (replaceRaw) {
206
- const BunRedisClient = this.raw
207
- .constructor;
208
- this.raw = new BunRedisClient(this.raw.url);
248
+ this.raw = await this._duplicateRaw(this.raw);
209
249
  this._setupCallbacks();
210
250
  }
211
251
  this.connecting = this.raw
@@ -221,6 +261,31 @@ class BunRedisAdapter extends EventEmitter {
221
261
  });
222
262
  }
223
263
  await this.connecting;
264
+ await this.readying;
265
+ // Bun may report the socket as connected before this adapter transitions
266
+ // to ready (for example while applying CLIENT SETNAME on duplicates).
267
+ // Keep connect() aligned with ioredis semantics by waiting until the
268
+ // adapter is either ready or closed.
269
+ if (!this.ready && !this.closed && !this.closing && ((_b = this.raw) === null || _b === void 0 ? void 0 : _b.connected)) {
270
+ await new Promise(resolve => {
271
+ var _a;
272
+ const cleanup = () => {
273
+ this.off('ready', onDone);
274
+ this.off('close', onDone);
275
+ this.off('end', onDone);
276
+ };
277
+ const onDone = () => {
278
+ cleanup();
279
+ resolve();
280
+ };
281
+ this.on('ready', onDone);
282
+ this.on('close', onDone);
283
+ this.on('end', onDone);
284
+ if (this.ready || this.closed || this.closing || !((_a = this.raw) === null || _a === void 0 ? void 0 : _a.connected)) {
285
+ onDone();
286
+ }
287
+ });
288
+ }
224
289
  }
225
290
  _closeRaw() {
226
291
  // Cancel any pending reconnect
@@ -229,7 +294,12 @@ class BunRedisAdapter extends EventEmitter {
229
294
  this.reconnectTimer = null;
230
295
  }
231
296
  this.reconnecting = false;
297
+ // A duplicate closed before it ever connected has no raw client yet.
298
+ this.rawFactory = undefined;
232
299
  const raw = this.raw;
300
+ if (!raw) {
301
+ return;
302
+ }
233
303
  raw.onconnect = () => { };
234
304
  raw.onclose = () => { };
235
305
  raw.onerror = () => { };
@@ -259,18 +329,20 @@ class BunRedisAdapter extends EventEmitter {
259
329
  this.closed = true;
260
330
  this.statusOverride = undefined;
261
331
  const raw = this.raw;
262
- raw.onclose = () => { };
263
- if (raw.connected) {
264
- setImmediate(() => {
265
- try {
266
- if (raw.connected) {
267
- raw.close();
332
+ if (raw) {
333
+ raw.onclose = () => { };
334
+ if (raw.connected) {
335
+ setImmediate(() => {
336
+ try {
337
+ if (raw.connected) {
338
+ raw.close();
339
+ }
268
340
  }
269
- }
270
- catch (_err) {
271
- // swallow
272
- }
273
- });
341
+ catch (_err) {
342
+ // swallow
343
+ }
344
+ });
345
+ }
274
346
  }
275
347
  this.emit('close');
276
348
  this._scheduleReconnect();
@@ -303,15 +375,45 @@ class BunRedisAdapter extends EventEmitter {
303
375
  });
304
376
  return 'OK';
305
377
  }
378
+ /**
379
+ * Create a fresh raw client aimed at the *same* Redis server as `src`.
380
+ *
381
+ * Bun's `RedisClient` exposes no public connection info (no `url`, host,
382
+ * port or options), so the target cannot be reconstructed from the instance.
383
+ * Its native `duplicate()` is the only reliable way to clone the target and
384
+ * options; we fall back to URL-based reconstruction for exotic raw clients
385
+ * that don't implement it. See #4582.
386
+ */
387
+ async _duplicateRaw(src) {
388
+ if (typeof src.duplicate === 'function') {
389
+ return await src.duplicate();
390
+ }
391
+ const Ctor = src.constructor;
392
+ return new Ctor(src.url);
393
+ }
394
+ /**
395
+ * Return the raw client, materializing it first when this adapter is a
396
+ * lazily-initialized duplicate (created via `duplicate()` with a
397
+ * `rawFactory`). Command paths that touch `this.raw` directly use this so a
398
+ * duplicate can be used immediately without an explicit `connect()`.
399
+ */
400
+ async _ensureRaw() {
401
+ if (!this.raw) {
402
+ await this.connect();
403
+ }
404
+ return this.raw;
405
+ }
306
406
  duplicate(...args) {
307
- // Bun's duplicate() is async, but IRedisClient.duplicate() is sync.
308
- // We create a new RedisClient with the same URL/options instead.
309
- // The raw client constructor in Bun doesn't connect until connect() or
310
- // first command, so this is safe.
311
- const BunRedisClient = this.raw
312
- .constructor;
313
- const dup = new BunRedisClient(this.raw.url);
314
- const adapter = new BunRedisAdapter(dup);
407
+ // Bun's duplicate() is async, but IRedisClient.duplicate() is sync. The
408
+ // duplicate adapter is therefore created immediately with a `rawFactory`
409
+ // that clones the connection target lazily (via Bun's native duplicate)
410
+ // on first connect. Rebuilding from `this.raw.url` is not possible because
411
+ // Bun never exposes the URL, which previously sent duplicates to the
412
+ // wrong (default) server (#4582).
413
+ const parentRaw = this.raw;
414
+ const adapter = new BunRedisAdapter(undefined, {
415
+ rawFactory: () => this._duplicateRaw(parentRaw),
416
+ });
315
417
  // Copy registered scripts to the duplicate
316
418
  for (const [name, script] of this.scripts) {
317
419
  adapter.scripts.set(name, script);
@@ -408,6 +510,14 @@ class BunRedisAdapter extends EventEmitter {
408
510
  if (this.closing || this.closed) {
409
511
  return Promise.reject(new ConnectionClosedError('Connection is closed'));
410
512
  }
513
+ // A duplicate created via `duplicate()` builds its raw client lazily on the
514
+ // first `connect()` (Bun's native `duplicate()` is async). Materialize it
515
+ // here so commands issued before an explicit `connect()` don't throw on an
516
+ // undefined `raw` — matching other adapters where duplicates connect
517
+ // implicitly on first use.
518
+ if (!this.raw) {
519
+ return this.connect().then(() => this.sendCommand(command, args));
520
+ }
411
521
  // Send directly to the underlying Bun client. Redis protocol guarantees
412
522
  // responses arrive in the same order as requests on a single connection,
413
523
  // so concurrent send() calls are safe and enable implicit pipelining
@@ -437,10 +547,10 @@ class BunRedisAdapter extends EventEmitter {
437
547
  // Pipeline / Transaction
438
548
  // ---------------------------------------------------------------
439
549
  multi() {
440
- return new BunRedisTransaction(this.raw, this.scripts, true, this);
550
+ return new BunRedisTransaction(this.scripts, true, this);
441
551
  }
442
552
  pipeline() {
443
- return new BunRedisTransaction(this.raw, this.scripts, false, this);
553
+ return new BunRedisTransaction(this.scripts, false, this);
444
554
  }
445
555
  // ---------------------------------------------------------------
446
556
  // Hash commands
@@ -603,7 +713,7 @@ class BunRedisAdapter extends EventEmitter {
603
713
  for (const [k, v] of Object.entries(fields)) {
604
714
  args.push(k, String(v));
605
715
  }
606
- return await this.raw.send('XADD', args);
716
+ return await (await this._ensureRaw()).send('XADD', args);
607
717
  }
608
718
  async xread(streams, options) {
609
719
  const args = [];
@@ -830,8 +940,7 @@ class BunRedisAdapter extends EventEmitter {
830
940
  // them within a MULTI/EXEC block using send().
831
941
  // ---------------------------------------------------------------------------
832
942
  class BunRedisTransaction {
833
- constructor(raw, scripts, transactional, adapter) {
834
- this.raw = raw;
943
+ constructor(scripts, transactional, adapter) {
835
944
  this.scripts = scripts;
836
945
  this.transactional = transactional;
837
946
  this.adapter = adapter;
@@ -993,15 +1102,18 @@ class BunRedisTransaction {
993
1102
  const swallow = (_) => {
994
1103
  /* error surfaces via EXEC or the outer try/catch */
995
1104
  };
1105
+ // Materialize the raw client (a lazily-initialized duplicate may not have
1106
+ // one yet) so the MULTI…EXEC frames can be written as a contiguous burst.
1107
+ const raw = await this.adapter._ensureRaw();
996
1108
  try {
997
1109
  // Fire MULTI without awaiting — no round-trip needed before commands.
998
- this.raw.send('MULTI', []).catch(swallow);
1110
+ raw.send('MULTI', []).catch(swallow);
999
1111
  // Fire all queued commands synchronously (no awaits).
1000
1112
  for (const { cmd, args } of this.commands) {
1001
- this.raw.send(cmd, args).catch(swallow);
1113
+ raw.send(cmd, args).catch(swallow);
1002
1114
  }
1003
1115
  // EXEC is the only await — it returns the array of results.
1004
- const results = await this.raw.send('EXEC', []);
1116
+ const results = await raw.send('EXEC', []);
1005
1117
  if (!results) {
1006
1118
  return null;
1007
1119
  }
@@ -1018,7 +1130,7 @@ class BunRedisTransaction {
1018
1130
  catch (err) {
1019
1131
  // Try to discard the MULTI state on error
1020
1132
  try {
1021
- await this.raw.send('DISCARD', []);
1133
+ await raw.send('DISCARD', []);
1022
1134
  }
1023
1135
  catch (_a) {
1024
1136
  // ignore
@@ -9,7 +9,7 @@ const packer = new Packr({
9
9
  encodeUndefinedAsNil: true,
10
10
  });
11
11
  const pack = packer.pack;
12
- import { array2obj, clientCommandMessageReg, errorObject, getParentKey, isEmpty, isRedisVersionLowerThan, objectToFlatArray, optsDecodeMap, optsEncodeMap, parseObjectValues, tryCatch, } from '../utils';
12
+ import { array2obj, clientCommandMessageReg, errorObject, getParentKey, isEmpty, isRedisCluster, isRedisVersionLowerThan, objectToFlatArray, optsDecodeMap, optsEncodeMap, parseObjectValues, tryCatch, } from '../utils';
13
13
  import { version as packageVersion } from '../version';
14
14
  import { finishedErrors } from './finished-errors';
15
15
  import { QueueKeys } from './queue-keys';
@@ -20,6 +20,44 @@ import { QueueKeys } from './queue-keys';
20
20
  * scan the whole state unboundedly.
21
21
  */
22
22
  const GET_JOBS_MAX_BACKFILL_ITERATIONS = 5;
23
+ /**
24
+ * Whether the client currently has a live connection. IORedis Cluster reports a
25
+ * usable connection as `connect` rather than `ready` (see
26
+ * `RedisConnection.waitUntilReady`).
27
+ */
28
+ function isClientLive(client) {
29
+ return (client.status === 'ready' ||
30
+ (client.status === 'connect' && isRedisCluster(client)));
31
+ }
32
+ /**
33
+ * Resets a connection whose blocking command was abandoned by a watchdog.
34
+ *
35
+ * Under `maxRetriesPerRequest: null` IORedis silently re-queues and re-sends
36
+ * an interrupted blocking command instead of rejecting it, so the abandoned
37
+ * command outlives a reconnect and would be served ahead of the next blocking
38
+ * read. Getting rid of it requires tearing the socket down while it is live.
39
+ *
40
+ * If the client is currently socketless (`reconnecting`) we must NOT
41
+ * disconnect it: that clears IORedis' own retry timer without emitting a
42
+ * `close` event and parks it in `reconnecting` forever (#4585). Instead we
43
+ * wait (via `reconnect`) for it to reach a live state and only then tear the
44
+ * socket down, waiting for it to actually close (`disconnect(true)` awaits
45
+ * the `end` event) before reconnecting again: IORedis closes the socket
46
+ * asynchronously, so a bare `disconnect(false)` leaves `status` transiently
47
+ * `ready`, `reconnect()` would observe the stale status, return a no-op, and
48
+ * the pending close would kill the connection for good (#4585 ready-path
49
+ * race).
50
+ */
51
+ async function resetBlockedConnection(connection, client, reconnect) {
52
+ if (!isClientLive(client)) {
53
+ // Let IORedis' own retry timer bring the connection back first.
54
+ await reconnect();
55
+ }
56
+ if (isClientLive(client)) {
57
+ await connection.disconnect(true);
58
+ await reconnect();
59
+ }
60
+ }
23
61
  export class RedisQueueBackend extends EventEmitter {
24
62
  constructor(connection, name, keys, toKey, opts, blockingConnection, ownsConnection = true) {
25
63
  var _a;
@@ -1826,7 +1864,7 @@ export class RedisQueueBackend extends EventEmitter {
1826
1864
  // Worker blocking primitive (previously bzpopmin in Worker)
1827
1865
  // ============================================================
1828
1866
  async waitForJob(blockTimeout) {
1829
- var _a;
1867
+ var _a, _b;
1830
1868
  const conn = (_a = this.blockingConnection) !== null && _a !== void 0 ? _a : this.connection;
1831
1869
  const bclient = (await this.queue.blockingClient);
1832
1870
  const roundedTimeout = conn.capabilities.canDoubleTimeout
@@ -1855,7 +1893,9 @@ export class RedisQueueBackend extends EventEmitter {
1855
1893
  const timeout = new Promise(resolve => {
1856
1894
  watchdog = setTimeout(() => {
1857
1895
  timedOut = true;
1858
- bclient.disconnect(false);
1896
+ // Resolve the wait as a timeout so the worker loop always advances.
1897
+ // The (possibly stuck) command is abandoned and the connection is
1898
+ // re-established in the `finally` block — see the note there.
1859
1899
  resolve(null);
1860
1900
  }, roundedTimeout * 1000 + 1000);
1861
1901
  });
@@ -1871,16 +1911,16 @@ export class RedisQueueBackend extends EventEmitter {
1871
1911
  }
1872
1912
  finally {
1873
1913
  clearTimeout(watchdog);
1874
- // The watchdog disconnected the blocking connection without letting
1875
- // IORedis auto-resend the abandoned command. Since we resolved the wait
1876
- // as a timeout (rather than surfacing a rejection to the worker's own
1877
- // reconnect path), re-establish the dedicated blocking connection here so
1878
- // the next `waitForJob` starts from a healthy, unblocked connection.
1914
+ // The watchdog resolved the wait as a timeout because the awaited command
1915
+ // never settled. Reset the dedicated blocking connection so the next
1916
+ // `waitForJob` starts from a healthy, unblocked connection and the
1917
+ // abandoned command is actually dropped — see
1918
+ // `resetBlockedConnection()`.
1879
1919
  if (timedOut && !this.closing) {
1880
1920
  try {
1881
- await this.reconnectBlocking();
1921
+ await resetBlockedConnection((_b = this.blockingConnection) !== null && _b !== void 0 ? _b : this.connection, bclient, () => this.reconnectBlocking());
1882
1922
  }
1883
- catch (_b) {
1923
+ catch (_c) {
1884
1924
  // Ignored: the next waitForJob call will retry the reconnect.
1885
1925
  }
1886
1926
  }
@@ -1920,7 +1960,9 @@ export class RedisQueueBackend extends EventEmitter {
1920
1960
  const timeout = new Promise(resolve => {
1921
1961
  watchdog = setTimeout(() => {
1922
1962
  timedOut = true;
1923
- client.disconnect(false);
1963
+ // Resolve as a timeout so the consumer loop always advances. The
1964
+ // (possibly stuck) command is abandoned and the connection
1965
+ // re-established in the `finally` block — see the note there.
1924
1966
  resolve(null);
1925
1967
  }, blockTimeout + 1000);
1926
1968
  });
@@ -1929,12 +1971,12 @@ export class RedisQueueBackend extends EventEmitter {
1929
1971
  }
1930
1972
  finally {
1931
1973
  clearTimeout(watchdog);
1932
- // The watchdog disconnected the connection without letting IORedis
1933
- // auto-resend the abandoned command. Re-establish it here so the next
1934
- // read starts from a healthy, unblocked connection.
1974
+ // Reset the connection so the next read starts healthy and the abandoned
1975
+ // command is actually dropped. Mirrors the `waitForJob` reset — see
1976
+ // `resetBlockedConnection()`.
1935
1977
  if (timedOut && !this.closing) {
1936
1978
  try {
1937
- await this.connection.reconnect();
1979
+ await resetBlockedConnection(this.connection, client, () => this.connection.reconnect());
1938
1980
  }
1939
1981
  catch (_a) {
1940
1982
  // Ignored: the next readEvents call will retry the reconnect.