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.
- package/dist/cjs/classes/errors/connection-closed-error.js +10 -2
- package/dist/cjs/classes/redis-connection.js +18 -21
- package/dist/cjs/classes/redis-queue-backend.js +85 -8
- package/dist/cjs/postgres/sql-loader.js +22 -2
- package/dist/cjs/tsconfig-cjs.tsbuildinfo +1 -1
- package/dist/cjs/utils/index.js +1 -4
- package/dist/cjs/version.js +1 -1
- package/dist/esm/classes/errors/connection-closed-error.d.ts +8 -0
- package/dist/esm/classes/errors/connection-closed-error.js +9 -1
- package/dist/esm/classes/redis-connection.js +1 -4
- package/dist/esm/classes/redis-queue-backend.js +85 -8
- package/dist/esm/postgres/sql-loader.js +23 -3
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils/index.d.ts +6 -2
- package/dist/esm/utils/index.js +1 -4
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +7 -7
|
@@ -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 :
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
223
|
-
canBlockFor1Ms: !(0,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1833
|
-
//
|
|
1834
|
-
//
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
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
|
|
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
|
-
|
|
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)(
|
|
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)(
|
|
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) {
|