bullmq 5.77.2 → 5.77.4

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.
@@ -85,9 +85,6 @@ class BunRedisAdapter extends events_1.EventEmitter {
85
85
  this.hasConnected = false;
86
86
  this.closed = false;
87
87
  this.closing = false;
88
- // Serialize raw send() calls per connection to avoid Bun delivering
89
- // concurrent command responses to the wrong pending promise.
90
- this.sendQueue = Promise.resolve();
91
88
  // Auto-reconnect state
92
89
  this.reconnecting = false;
93
90
  this.reconnectTimer = null;
@@ -404,31 +401,24 @@ class BunRedisAdapter extends events_1.EventEmitter {
404
401
  }));
405
402
  }
406
403
  sendCommand(command, args) {
407
- // If the connection is already closing/closed, return a rejected promise
408
- // directly without going through sendQueue.
404
+ // If the connection is already closing/closed, return a rejected promise.
409
405
  if (this.closing || this.closed) {
410
406
  return Promise.reject(new connection_closed_error_1.ConnectionClosedError('Connection is closed'));
411
407
  }
412
- const run = this.sendQueue.then(() => {
413
- if (this.closing || this.closed) {
414
- return Promise.reject(new connection_closed_error_1.ConnectionClosedError('Connection is closed'));
408
+ // Send directly to the underlying Bun client. Redis protocol guarantees
409
+ // responses arrive in the same order as requests on a single connection,
410
+ // so concurrent send() calls are safe and enable implicit pipelining
411
+ // (multiple commands written to the socket before any response is read).
412
+ // MULTI/EXEC transactions don't go through this path — they are issued
413
+ // as a synchronous burst of raw `send()` calls in
414
+ // `BunRedisTransaction.exec()`, which guarantees the MULTI…EXEC frames
415
+ // are written contiguously without any other command interleaving.
416
+ return this.raw.send(command, args).catch((err) => {
417
+ if (isBunConnectionClosedError(err)) {
418
+ return Promise.reject(new connection_closed_error_1.ConnectionClosedError(this.closing || this.closed ? 'Connection is closed' : err.message, err));
415
419
  }
416
- return this.raw.send(command, args).catch((err) => {
417
- if (isBunConnectionClosedError(err)) {
418
- return Promise.reject(new connection_closed_error_1.ConnectionClosedError(this.closing || this.closed
419
- ? 'Connection is closed'
420
- : err.message, err));
421
- }
422
- throw err;
423
- });
420
+ throw err;
424
421
  });
425
- this.sendQueue = run.then(() => undefined, () => undefined);
426
- return run;
427
- }
428
- async queueExclusive(operation) {
429
- const run = this.sendQueue.then(operation, operation);
430
- this.sendQueue = run.then(() => undefined, () => undefined);
431
- return run;
432
422
  }
433
423
  // ---------------------------------------------------------------
434
424
  // Pipeline / Transaction
@@ -973,41 +963,54 @@ class BunRedisTransaction {
973
963
  return [null, value];
974
964
  });
975
965
  }
976
- // Execute as one exclusive queued operation so no command can be interleaved
977
- // between MULTI and EXEC on this connection.
978
- return this.adapter.queueExclusive(async () => {
979
- try {
980
- await this.raw.send('MULTI', []);
981
- // Queue all MULTI commands in the same turn without awaiting each send.
982
- // This avoids yielding between command enqueues before EXEC is issued.
983
- const queuedCommandPromises = this.commands.map(({ cmd, args }) => this.raw.send(cmd, args));
984
- // Issue EXEC immediately after enqueuing queued commands.
985
- const results = await this.raw.send('EXEC', []);
986
- // Prevent unhandled rejections from queued command promises.
987
- await Promise.allSettled(queuedCommandPromises);
988
- if (!results) {
989
- return null;
990
- }
991
- // Normalize to ioredis format: [Error | null, value][]
992
- return results.map((result, i) => {
993
- if (result instanceof Error) {
994
- return [result, null];
995
- }
996
- const transformer = this.transformers[i];
997
- const value = transformer ? transformer(result) : result;
998
- return [null, value];
999
- });
966
+ // Execute as a pipelined MULTI/EXEC block. Redis supports multiple
967
+ // MULTI/EXEC blocks pipelined on the same connection — each MULTI starts
968
+ // a new transaction context and EXEC commits it, responses arrive in
969
+ // order. We fire MULTI + all commands + EXEC synchronously (no await
970
+ // between them) so they're written to the socket buffer as one contiguous
971
+ // burst with no opportunity for interleaving from other async contexts.
972
+ //
973
+ // The MULTI and per-command `send()` calls are intentionally fire-and-
974
+ // forget for performance, but their returned promises must still have a
975
+ // rejection handler attached to avoid Bun emitting "unhandled promise
976
+ // rejection" warnings if the connection drops or Redis returns an error
977
+ // reply before we reach EXEC. The actual failure is reported through the
978
+ // awaited EXEC promise (which Redis rejects in the same situations), or
979
+ // bubbles up via the surrounding `try/catch`.
980
+ const swallow = (_) => {
981
+ /* error surfaces via EXEC or the outer try/catch */
982
+ };
983
+ try {
984
+ // Fire MULTI without awaiting — no round-trip needed before commands.
985
+ this.raw.send('MULTI', []).catch(swallow);
986
+ // Fire all queued commands synchronously (no awaits).
987
+ for (const { cmd, args } of this.commands) {
988
+ this.raw.send(cmd, args).catch(swallow);
1000
989
  }
1001
- catch (err) {
1002
- // Try to discard the MULTI state on error
1003
- try {
1004
- await this.raw.send('DISCARD', []);
1005
- }
1006
- catch (_a) {
1007
- // ignore
990
+ // EXEC is the only await — it returns the array of results.
991
+ const results = await this.raw.send('EXEC', []);
992
+ if (!results) {
993
+ return null;
994
+ }
995
+ // Normalize to ioredis format: [Error | null, value][]
996
+ return results.map((result, i) => {
997
+ if (result instanceof Error) {
998
+ return [result, null];
1008
999
  }
1009
- throw err;
1000
+ const transformer = this.transformers[i];
1001
+ const value = transformer ? transformer(result) : result;
1002
+ return [null, value];
1003
+ });
1004
+ }
1005
+ catch (err) {
1006
+ // Try to discard the MULTI state on error
1007
+ try {
1008
+ await this.raw.send('DISCARD', []);
1010
1009
  }
1011
- });
1010
+ catch (_a) {
1011
+ // ignore
1012
+ }
1013
+ throw err;
1014
+ }
1012
1015
  }
1013
1016
  }
@@ -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