bullmq 6.0.6 → 6.0.8

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.
@@ -1,6 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ConnectionClosedError = void 0;
3
+ exports.ConnectionClosedError = exports.CONNECTION_CLOSED_ERROR_MSG = void 0;
4
+ /**
5
+ * Error message used when a connection is closed.
6
+ *
7
+ * This mirrors the constant exported by ioredis (`ioredis/built/utils`) but is
8
+ * defined locally so that BullMQ can be used without ioredis installed (for
9
+ * example, when using the PostgreSQL backend).
10
+ */
11
+ exports.CONNECTION_CLOSED_ERROR_MSG = 'Connection is closed.';
4
12
  /**
5
13
  * Thrown by any BullMQ Redis adapter (ioredis, node-redis, Bun, …) when a
6
14
  * command fails because the connection is already closed or was closed
@@ -11,7 +19,7 @@ exports.ConnectionClosedError = void 0;
11
19
  */
12
20
  class ConnectionClosedError extends Error {
13
21
  constructor(message, cause) {
14
- super(message !== null && message !== void 0 ? message : 'Connection is closed');
22
+ super(message !== null && message !== void 0 ? message : exports.CONNECTION_CLOSED_ERROR_MSG);
15
23
  this.cause = cause;
16
24
  this.name = 'ConnectionClosedError';
17
25
  Object.setPrototypeOf(this, new.target.prototype);
@@ -4,10 +4,7 @@ exports.RedisConnection = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const events_1 = require("events");
6
6
  const ioredis_1 = require("ioredis");
7
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
8
- // @ts-ignore
9
- const utils_1 = require("ioredis/built/utils");
10
- const utils_2 = require("../utils");
7
+ const utils_1 = require("../utils");
11
8
  const version_1 = require("../version");
12
9
  const scripts = require("../scripts");
13
10
  const ioredis_client_1 = require("./ioredis-client");
@@ -44,7 +41,7 @@ class RedisConnection extends events_1.EventEmitter {
44
41
  this.disabledBlockingClusterReconnect = false;
45
42
  // Set extra options defaults
46
43
  this.extraOptions = Object.assign({ shared: false, blocking: true, skipVersionCheck: false, skipWaitingForReady: false, clusterReconnectTimeoutMs: DEFAULT_CLUSTER_RECONNECT_TIMEOUT_MS }, extraOptions);
47
- if (!(0, utils_2.isRedisInstance)(opts)) {
44
+ if (!(0, utils_1.isRedisInstance)(opts)) {
48
45
  this.checkBlockingOptions(overrideMessage, opts);
49
46
  this.opts = Object.assign({ port: 6379, host: '127.0.0.1', retryStrategy: function (times) {
50
47
  return Math.max(Math.min(Math.exp(times), 20000), 1000);
@@ -118,14 +115,14 @@ class RedisConnection extends events_1.EventEmitter {
118
115
  // 'ready' that standalone Redis uses. Treat it as already connected so we
119
116
  // don't hang waiting for a 'ready' event that will never fire and end up
120
117
  // throwing a spurious "Connection is closed" error during disconnect.
121
- if (client.status === 'connect' && (0, utils_2.isRedisCluster)(client)) {
118
+ if (client.status === 'connect' && (0, utils_1.isRedisCluster)(client)) {
122
119
  return;
123
120
  }
124
121
  if (client.status === 'wait') {
125
122
  return client.connect();
126
123
  }
127
124
  if (client.status === 'end') {
128
- throw new connection_closed_error_1.ConnectionClosedError(utils_1.CONNECTION_CLOSED_ERROR_MSG);
125
+ throw new connection_closed_error_1.ConnectionClosedError(connection_closed_error_1.CONNECTION_CLOSED_ERROR_MSG);
129
126
  }
130
127
  let handleReady;
131
128
  let handleEnd;
@@ -142,7 +139,7 @@ class RedisConnection extends events_1.EventEmitter {
142
139
  handleEnd = () => {
143
140
  if (client.status !== 'end') {
144
141
  reject(lastError ||
145
- new connection_closed_error_1.ConnectionClosedError(utils_1.CONNECTION_CLOSED_ERROR_MSG));
142
+ new connection_closed_error_1.ConnectionClosedError(connection_closed_error_1.CONNECTION_CLOSED_ERROR_MSG));
146
143
  }
147
144
  else {
148
145
  if (lastError) {
@@ -154,7 +151,7 @@ class RedisConnection extends events_1.EventEmitter {
154
151
  }
155
152
  }
156
153
  };
157
- (0, utils_2.increaseMaxListeners)(client, 3);
154
+ (0, utils_1.increaseMaxListeners)(client, 3);
158
155
  client.once('ready', handleReady);
159
156
  client.on('end', handleEnd);
160
157
  client.once('error', handleError);
@@ -164,7 +161,7 @@ class RedisConnection extends events_1.EventEmitter {
164
161
  client.removeListener('end', handleEnd);
165
162
  client.removeListener('error', handleError);
166
163
  client.removeListener('ready', handleReady);
167
- (0, utils_2.decreaseMaxListeners)(client, 3);
164
+ (0, utils_1.decreaseMaxListeners)(client, 3);
168
165
  }
169
166
  }
170
167
  get client() {
@@ -194,7 +191,7 @@ class RedisConnection extends events_1.EventEmitter {
194
191
  this._client = (0, ioredis_client_1.createIORedisClient)(ioredisClient);
195
192
  }
196
193
  }
197
- (0, utils_2.increaseMaxListeners)(this._client, 3);
194
+ (0, utils_1.increaseMaxListeners)(this._client, 3);
198
195
  this._client.on('error', this.handleClientError);
199
196
  // ioredis treats connection errors as a different event ('close')
200
197
  this._client.on('close', this.handleClientClose);
@@ -209,18 +206,18 @@ class RedisConnection extends events_1.EventEmitter {
209
206
  this.version = versionResult.version;
210
207
  this.dbType = versionResult.databaseType;
211
208
  if (this.skipVersionCheck !== true && !this.closing) {
212
- if ((0, utils_2.isRedisVersionLowerThan)(this.version, RedisConnection.minimumVersion, this.dbType)) {
209
+ if ((0, utils_1.isRedisVersionLowerThan)(this.version, RedisConnection.minimumVersion, this.dbType)) {
213
210
  throw new Error(`Redis version needs to be greater or equal than ${RedisConnection.minimumVersion} ` +
214
211
  `Current: ${this.version}`);
215
212
  }
216
- if ((0, utils_2.isRedisVersionLowerThan)(this.version, RedisConnection.recommendedMinimumVersion, this.dbType)) {
213
+ if ((0, utils_1.isRedisVersionLowerThan)(this.version, RedisConnection.recommendedMinimumVersion, this.dbType)) {
217
214
  console.warn(`It is highly recommended to use a minimum Redis version of ${RedisConnection.recommendedMinimumVersion}
218
215
  Current: ${this.version}`);
219
216
  }
220
217
  }
221
218
  this.capabilities = {
222
- canDoubleTimeout: !(0, utils_2.isRedisVersionLowerThan)(this.version, '6.0.0', this.dbType),
223
- canBlockFor1Ms: !(0, utils_2.isRedisVersionLowerThan)(this.version, '7.0.8', this.dbType),
219
+ canDoubleTimeout: !(0, utils_1.isRedisVersionLowerThan)(this.version, '6.0.0', this.dbType),
220
+ canBlockFor1Ms: !(0, utils_1.isRedisVersionLowerThan)(this.version, '7.0.8', this.dbType),
224
221
  };
225
222
  this.status = 'ready';
226
223
  }
@@ -231,7 +228,7 @@ class RedisConnection extends events_1.EventEmitter {
231
228
  const client = this._client;
232
229
  const blockingClient = client;
233
230
  if (!this.extraOptions.blocking ||
234
- !(0, utils_2.isRedisCluster)(client) ||
231
+ !(0, utils_1.isRedisCluster)(client) ||
235
232
  typeof blockingClient.bzpopmin !== 'function') {
236
233
  return;
237
234
  }
@@ -384,7 +381,7 @@ class RedisConnection extends events_1.EventEmitter {
384
381
  return client.disconnect();
385
382
  }
386
383
  const disconnecting = new Promise((resolve, reject) => {
387
- (0, utils_2.increaseMaxListeners)(client, 2);
384
+ (0, utils_1.increaseMaxListeners)(client, 2);
388
385
  client.once('end', resolve);
389
386
  client.once('error', reject);
390
387
  _resolve = resolve;
@@ -395,7 +392,7 @@ class RedisConnection extends events_1.EventEmitter {
395
392
  await disconnecting;
396
393
  }
397
394
  finally {
398
- (0, utils_2.decreaseMaxListeners)(client, 2);
395
+ (0, utils_1.decreaseMaxListeners)(client, 2);
399
396
  client.removeListener('end', _resolve);
400
397
  client.removeListener('error', _reject);
401
398
  }
@@ -405,7 +402,7 @@ class RedisConnection extends events_1.EventEmitter {
405
402
  const client = await this.client;
406
403
  for (;;) {
407
404
  if (client.status === 'ready' ||
408
- (client.status === 'connect' && (0, utils_2.isRedisCluster)(client))) {
405
+ (client.status === 'connect' && (0, utils_1.isRedisCluster)(client))) {
409
406
  return;
410
407
  }
411
408
  if (client.status === 'wait' || client.status === 'end') {
@@ -449,7 +446,7 @@ class RedisConnection extends events_1.EventEmitter {
449
446
  }
450
447
  }
451
448
  catch (error) {
452
- if ((0, utils_2.isNotConnectionError)(error)) {
449
+ if ((0, utils_1.isNotConnectionError)(error)) {
453
450
  throw error;
454
451
  }
455
452
  }
@@ -458,7 +455,7 @@ class RedisConnection extends events_1.EventEmitter {
458
455
  this._client.off('error', this.handleClientError);
459
456
  this._client.off('close', this.handleClientClose);
460
457
  this._client.off('ready', this.handleClientReady);
461
- (0, utils_2.decreaseMaxListeners)(this._client, 3);
458
+ (0, utils_1.decreaseMaxListeners)(this._client, 3);
462
459
  this.removeAllListeners();
463
460
  this.status = 'closed';
464
461
  }
@@ -1829,14 +1829,35 @@ class RedisQueueBackend extends events_1.EventEmitter {
1829
1829
  const roundedTimeout = conn.capabilities.canDoubleTimeout
1830
1830
  ? blockTimeout
1831
1831
  : Math.ceil(blockTimeout);
1832
- // We cannot trust that the blocking connection stays blocking forever due
1833
- // to issues in Redis and IORedis, so we reconnect the (owned) blocking
1834
- // connection if we don't get a response within the expected time.
1835
- const watchdog = setTimeout(() => {
1836
- bclient.disconnect(!this.closing);
1837
- }, roundedTimeout * 1000 + 1000);
1832
+ const bzpopmin = bclient.bzpopmin(this.queue.keys.marker, roundedTimeout);
1833
+ // If the watchdog abandons this command below, its (possibly much later)
1834
+ // rejection must not surface as an unhandled promise rejection.
1835
+ bzpopmin.catch(() => null);
1836
+ // We cannot trust that the blocking connection stays blocking for exactly
1837
+ // the expected time due to issues in Redis and IORedis. In particular,
1838
+ // blocking connections must use `maxRetriesPerRequest: null`, and with that
1839
+ // setting IORedis silently re-queues and re-sends an interrupted blocking
1840
+ // command after a reconnect instead of rejecting it (see #4479). As a
1841
+ // result the awaited `bzpopmin` can hang forever after a transient
1842
+ // connection reset, parking the worker's fetch loop permanently.
1843
+ //
1844
+ // To make this robust we race the blocking command against a watchdog
1845
+ // timeout: when the deadline passes we disconnect the (owned) blocking
1846
+ // connection to abandon the possibly-stuck command and resolve the wait as
1847
+ // a timeout, so the worker loop always advances (and can promote due
1848
+ // delayed jobs / write markers) regardless of whether IORedis ever settles
1849
+ // the command.
1850
+ let timedOut = false;
1851
+ let watchdog;
1852
+ const timeout = new Promise(resolve => {
1853
+ watchdog = setTimeout(() => {
1854
+ timedOut = true;
1855
+ bclient.disconnect(false);
1856
+ resolve(null);
1857
+ }, roundedTimeout * 1000 + 1000);
1858
+ });
1838
1859
  try {
1839
- const result = await bclient.bzpopmin(this.queue.keys.marker, roundedTimeout);
1860
+ const result = await Promise.race([bzpopmin, timeout]);
1840
1861
  if (result) {
1841
1862
  const [, member, score] = result;
1842
1863
  if (member) {
@@ -1847,6 +1868,19 @@ class RedisQueueBackend extends events_1.EventEmitter {
1847
1868
  }
1848
1869
  finally {
1849
1870
  clearTimeout(watchdog);
1871
+ // The watchdog disconnected the blocking connection without letting
1872
+ // IORedis auto-resend the abandoned command. Since we resolved the wait
1873
+ // as a timeout (rather than surfacing a rejection to the worker's own
1874
+ // reconnect path), re-establish the dedicated blocking connection here so
1875
+ // the next `waitForJob` starts from a healthy, unblocked connection.
1876
+ if (timedOut && !this.closing) {
1877
+ try {
1878
+ await this.reconnectBlocking();
1879
+ }
1880
+ catch (_b) {
1881
+ // Ignored: the next waitForJob call will retry the reconnect.
1882
+ }
1883
+ }
1850
1884
  }
1851
1885
  }
1852
1886
  async publishEvent(fields, maxEvents) {
@@ -1858,9 +1892,52 @@ class RedisQueueBackend extends events_1.EventEmitter {
1858
1892
  }
1859
1893
  async readEvents(id, blockTimeout) {
1860
1894
  const client = await this.queue.client;
1861
- return client.xread([{ key: this.queue.keys.events, id }], {
1895
+ const xread = client.xread([{ key: this.queue.keys.events, id }], {
1862
1896
  BLOCK: blockTimeout,
1863
1897
  });
1898
+ // Redis XREAD `BLOCK 0` means "block forever". If `blockTimeout` is <= 0 we
1899
+ // cannot enforce a bounded deadline, so fall back to the plain blocking read.
1900
+ if (blockTimeout <= 0) {
1901
+ return xread;
1902
+ }
1903
+ // If the watchdog abandons this command below, its (possibly much later)
1904
+ // rejection must not surface as an unhandled promise rejection.
1905
+ xread.catch(() => null);
1906
+ // Same class of hang as `waitForJob` (#4479): the event-stream connection
1907
+ // must use `maxRetriesPerRequest: null`, and with that setting IORedis
1908
+ // silently re-queues and re-sends an interrupted blocking `XREAD` after a
1909
+ // reconnect instead of rejecting it — so the awaited read can hang forever
1910
+ // after a transient connection reset, permanently stalling the event
1911
+ // consumer loop. Race the read against a watchdog: when the deadline passes
1912
+ // (the server-side BLOCK plus a small margin) we disconnect the connection
1913
+ // to abandon the stuck command and resolve as a timeout, so the consumer
1914
+ // loop always advances and can re-issue the read.
1915
+ let timedOut = false;
1916
+ let watchdog;
1917
+ const timeout = new Promise(resolve => {
1918
+ watchdog = setTimeout(() => {
1919
+ timedOut = true;
1920
+ client.disconnect(false);
1921
+ resolve(null);
1922
+ }, blockTimeout + 1000);
1923
+ });
1924
+ try {
1925
+ return (await Promise.race([xread, timeout]));
1926
+ }
1927
+ finally {
1928
+ clearTimeout(watchdog);
1929
+ // The watchdog disconnected the connection without letting IORedis
1930
+ // auto-resend the abandoned command. Re-establish it here so the next
1931
+ // read starts from a healthy, unblocked connection.
1932
+ if (timedOut && !this.closing) {
1933
+ try {
1934
+ await this.connection.reconnect();
1935
+ }
1936
+ catch (_a) {
1937
+ // Ignored: the next readEvents call will retry the reconnect.
1938
+ }
1939
+ }
1940
+ }
1864
1941
  }
1865
1942
  }
1866
1943
  exports.RedisQueueBackend = RedisQueueBackend;
@@ -4,6 +4,26 @@ exports.loadMigrationSql = loadMigrationSql;
4
4
  exports.loadCommandSql = loadCommandSql;
5
5
  const fs_1 = require("fs");
6
6
  const path_1 = require("path");
7
+ const url_1 = require("url");
8
+ function getDirname() {
9
+ if (typeof __dirname !== 'undefined' && __dirname) {
10
+ return __dirname;
11
+ }
12
+ const stack = new Error().stack || '';
13
+ for (const line of stack.split('\n')) {
14
+ const match = line.match(/(file:\/\/\/[^\s)]+)/);
15
+ if (match) {
16
+ try {
17
+ return (0, path_1.dirname)((0, url_1.fileURLToPath)(match[1]));
18
+ }
19
+ catch (_a) {
20
+ // continue searching
21
+ }
22
+ }
23
+ }
24
+ throw new Error('Could not determine sql-loader directory path');
25
+ }
26
+ const currentDir = getDirname();
7
27
  /**
8
28
  * Loads a migration's SQL from its `.sql` file — the portable source of truth
9
29
  * shared with the Elixir/Python ports. Results are cached after the first read.
@@ -13,7 +33,7 @@ const path_1 = require("path");
13
33
  * analogous to how the Lua scripts are bundled), so the same relative lookup
14
34
  * works at runtime.
15
35
  */
16
- const MIGRATIONS_DIR = (0, path_1.join)(__dirname, 'migrations');
36
+ const MIGRATIONS_DIR = (0, path_1.join)(currentDir, 'migrations');
17
37
  /**
18
38
  * Runtime queries live under `commands/`. Each `.sql` file is one parameterized
19
39
  * statement (a `SELECT fn(...)` for the PL/pgSQL operations, or a direct
@@ -22,7 +42,7 @@ const MIGRATIONS_DIR = (0, path_1.join)(__dirname, 'migrations');
22
42
  * Python/Elixir/PHP/Rust ports (mirroring how the Redis backend's `.lua`
23
43
  * scripts never hardcode the key prefix).
24
44
  */
25
- const COMMANDS_DIR = (0, path_1.join)(__dirname, 'commands');
45
+ const COMMANDS_DIR = (0, path_1.join)(currentDir, 'commands');
26
46
  const migrationCache = new Map();
27
47
  const commandCache = new Map();
28
48
  function loadMigrationSql(file) {