bullmq 6.3.1 → 6.3.2

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.
@@ -242,6 +242,14 @@ class RedisQueueBackend extends events_1.EventEmitter {
242
242
  ? 0.001
243
243
  : 0.002;
244
244
  }
245
+ /**
246
+ * Largest meaningful block timeout (seconds). Capped at 10s because a
247
+ * `BZPOPMIN` blocked longer than this risks issues on reconnection
248
+ * (see #1658).
249
+ */
250
+ get maximumBlockTimeout() {
251
+ return 10;
252
+ }
245
253
  /**
246
254
  * Interrupts the in-flight blocking wait by disconnecting the dedicated
247
255
  * blocking connection. No-op if there is none.
@@ -14,8 +14,9 @@ const errors_1 = require("./errors");
14
14
  const enums_1 = require("../enums");
15
15
  const job_scheduler_1 = require("./job-scheduler");
16
16
  const lock_manager_1 = require("./lock-manager");
17
- // 10 seconds is the maximum time a BZPOPMIN can block.
18
- const maximumBlockTimeout = 10;
17
+ // 10 seconds is the maximum time a BZPOPMIN can block, so it is the default
18
+ // ceiling used when a backend does not delegate its own `maximumBlockTimeout`.
19
+ const defaultMaximumBlockTimeout = 10;
19
20
  /**
20
21
  *
21
22
  * This class represents a worker that is able to process jobs from the queue.
@@ -396,6 +397,10 @@ class Worker extends queue_base_1.QueueBase {
396
397
  get minimumBlockTimeout() {
397
398
  return this.backend.minimumBlockTimeout;
398
399
  }
400
+ get maximumBlockTimeout() {
401
+ var _a;
402
+ return (_a = this.backend.maximumBlockTimeout) !== null && _a !== void 0 ? _a : defaultMaximumBlockTimeout;
403
+ }
399
404
  isRateLimited() {
400
405
  return this.limitUntil > Date.now();
401
406
  }
@@ -466,10 +471,13 @@ class Worker extends queue_base_1.QueueBase {
466
471
  return this.minimumBlockTimeout;
467
472
  }
468
473
  else {
469
- // We restrict the maximum block timeout to 10 second to avoid
470
- // blocking the connection for too long in the case of reconnections
471
- // reference: https://github.com/taskforcesh/bullmq/issues/1658
472
- return Math.min(blockDelay / 1000, maximumBlockTimeout);
474
+ // We restrict the maximum block timeout to avoid blocking the
475
+ // connection for too long in the case of reconnections. The ceiling is
476
+ // backend-specific: Redis caps it at 10s (a `BZPOPMIN` blocked longer
477
+ // risks issues on reconnection, see #1658), whereas a backend that
478
+ // keeps the connection open and re-arms to the next due job can allow a
479
+ // much larger value so an idle worker stops re-polling.
480
+ return Math.min(blockDelay / 1000, this.maximumBlockTimeout);
473
481
  }
474
482
  }
475
483
  else {
@@ -27,6 +27,34 @@ function loadPgModule() {
27
27
  'already-constructed `pg.Pool` instance as the connection instead of a ' +
28
28
  'config object or connection string.');
29
29
  }
30
+ /**
31
+ * Idle time (ms) before the first TCP keepalive probe is sent on the dedicated
32
+ * `LISTEN` connection. Short enough that a silently dropped connection is
33
+ * detected in seconds instead of the OS default (typically two hours) — which,
34
+ * with a large `maximumBlockTimeout`, is what a blocked worker would otherwise
35
+ * have to wait for.
36
+ */
37
+ const LISTEN_KEEPALIVE_INITIAL_DELAY_MS = 10000;
38
+ /**
39
+ * Best-effort: turns TCP keepalive on for an already-established client's
40
+ * socket. Needed for the pooled `LISTEN` client, since a user-supplied
41
+ * `pg.Pool` may have been created without `keepAlive` and a checked-out client
42
+ * inherits that configuration. Returns whether keepalive is now enabled.
43
+ */
44
+ function enableSocketKeepAlive(client) {
45
+ var _a;
46
+ const stream = (_a = client.connection) === null || _a === void 0 ? void 0 : _a.stream;
47
+ if (!stream || typeof stream.setKeepAlive !== 'function') {
48
+ return false;
49
+ }
50
+ try {
51
+ stream.setKeepAlive(true, LISTEN_KEEPALIVE_INITIAL_DELAY_MS);
52
+ return true;
53
+ }
54
+ catch (_b) {
55
+ return false;
56
+ }
57
+ }
30
58
  /**
31
59
  * Owns the PostgreSQL connection resources for a single backend:
32
60
  *
@@ -40,6 +68,7 @@ function loadPgModule() {
40
68
  */
41
69
  class PostgresConnection extends events_1.EventEmitter {
42
70
  constructor(connection) {
71
+ var _a;
43
72
  super();
44
73
  /**
45
74
  * `true` when {@link listenClient} is a standalone `pg.Client` we must `end()`
@@ -54,10 +83,13 @@ class PostgresConnection extends events_1.EventEmitter {
54
83
  this.migrateOnConnect = false;
55
84
  this.pgModule = undefined;
56
85
  this.listenClientConfig = undefined;
86
+ // A user-supplied pool decides its own `keepAlive`; when it is off we try
87
+ // to enable it on the checked-out LISTEN socket (see getListenClient).
88
+ this.listenClientKeepAlive = Boolean((_a = connection.options) === null || _a === void 0 ? void 0 : _a.keepAlive);
57
89
  }
58
90
  else {
59
91
  const pg = loadPgModule();
60
- const _a = typeof connection === 'string'
92
+ const _b = typeof connection === 'string'
61
93
  ? {
62
94
  schema: undefined,
63
95
  skipVersionCheck: undefined,
@@ -65,7 +97,7 @@ class PostgresConnection extends events_1.EventEmitter {
65
97
  skipMigrations: undefined,
66
98
  connectionString: connection,
67
99
  }
68
- : connection, { schema, skipVersionCheck, migrate, skipMigrations } = _a, poolConfig = tslib_1.__rest(_a, ["schema", "skipVersionCheck", "migrate", "skipMigrations"]);
100
+ : connection, { schema, skipVersionCheck, migrate, skipMigrations } = _b, poolConfig = tslib_1.__rest(_b, ["schema", "skipVersionCheck", "migrate", "skipMigrations"]);
69
101
  if (migrate !== undefined && skipMigrations !== undefined) {
70
102
  throw new Error('BullMQ: `migrate` and `skipMigrations` are mutually exclusive. Set only one.');
71
103
  }
@@ -88,6 +120,8 @@ class PostgresConnection extends events_1.EventEmitter {
88
120
  // Keep the means to build a dedicated LISTEN connection on demand.
89
121
  this.pgModule = pg;
90
122
  this.listenClientConfig = resolvedConfig;
123
+ // We build the LISTEN client ourselves, always with keepAlive enabled.
124
+ this.listenClientKeepAlive = true;
91
125
  }
92
126
  // The pool emits 'error' for idle clients that drop; surface it but never
93
127
  // let it crash the process — hence the guarded {@link emitError} (a bare
@@ -156,24 +190,117 @@ class PostgresConnection extends events_1.EventEmitter {
156
190
  if (!this.listenClientPromise) {
157
191
  this.listenClientPromise = (async () => {
158
192
  if (this.pgModule && this.listenClientConfig) {
159
- const client = new this.pgModule.Client(this.listenClientConfig);
193
+ const client = new this.pgModule.Client(Object.assign(Object.assign({}, this.listenClientConfig), {
194
+ // A LISTEN subscription is bound to one physical connection. Enable
195
+ // TCP keepalive so a silently dropped connection surfaces as a
196
+ // socket `'error'` (and is then rebuilt below) instead of going
197
+ // unnoticed until the next poll — which, with a large
198
+ // `maximumBlockTimeout`, could be up to an hour away.
199
+ keepAlive: true, keepAliveInitialDelayMillis: LISTEN_KEEPALIVE_INITIAL_DELAY_MS }));
160
200
  await client.connect();
161
- client.on('error', err => this.emitError(err));
201
+ client.on('error', err => this.handleListenClientError(client, err));
202
+ this.listenClientKeepAlive = true;
162
203
  this.listenClientIsStandalone = true;
163
204
  this.listenClient = client;
205
+ await this.applyListenClientName(client);
164
206
  return client;
165
207
  }
166
208
  else {
167
209
  const client = await this.pool.connect();
168
- client.on('error', err => this.emitError(err));
210
+ client.on('error', err => this.handleListenClientError(client, err));
211
+ // The pool is the user's, so its clients may have been created
212
+ // without `keepAlive`; enable it on this socket so a silent drop is
213
+ // still detected. If that is not possible the connection reports no
214
+ // keepalive and the backend caps its block timeout conservatively.
215
+ this.listenClientKeepAlive =
216
+ this.listenClientKeepAlive || enableSocketKeepAlive(client);
169
217
  this.listenClientIsStandalone = false;
170
218
  this.listenClient = client;
219
+ await this.applyListenClientName(client);
171
220
  return client;
172
221
  }
173
222
  })();
174
223
  }
175
224
  return this.listenClientPromise;
176
225
  }
226
+ /**
227
+ * Whether TCP keepalive is enabled on the dedicated `LISTEN` connection, so a
228
+ * silently dropped connection is detected (and the client rebuilt) instead of
229
+ * lingering unnoticed. Consulted by the backend when deciding how long a
230
+ * worker may block.
231
+ */
232
+ get hasListenClientKeepAlive() {
233
+ return this.listenClientKeepAlive;
234
+ }
235
+ /**
236
+ * Sets the `application_name` of the dedicated `LISTEN` connection — the
237
+ * PostgreSQL analogue of Redis `CLIENT SETNAME` — and remembers it so it is
238
+ * re-applied to any client rebuilt after a drop.
239
+ */
240
+ async setListenClientName(name) {
241
+ this.listenClientName = name;
242
+ const client = await this.getListenClient();
243
+ await client.query(`SELECT set_config('application_name', $1, false)`, [
244
+ name,
245
+ ]);
246
+ }
247
+ /**
248
+ * Re-applies the remembered {@link listenClientName} to a freshly established
249
+ * `LISTEN` client. Best-effort: discovery must never break the client.
250
+ */
251
+ async applyListenClientName(client) {
252
+ if (!this.listenClientName) {
253
+ return;
254
+ }
255
+ try {
256
+ await client.query(`SELECT set_config('application_name', $1, false)`, [
257
+ this.listenClientName,
258
+ ]);
259
+ }
260
+ catch (_a) {
261
+ // Discovery is best-effort; leave the connection unnamed.
262
+ }
263
+ }
264
+ /**
265
+ * Handles a fatal error on the dedicated `LISTEN` client.
266
+ *
267
+ * A `LISTEN` subscription lives on one specific physical connection: once that
268
+ * connection drops, the memoized client is dead and every re-`LISTEN` issued
269
+ * on it is silently lost — so a blocked worker would stop receiving NOTIFYs
270
+ * until its next poll. Invalidate the memo (and tear the dead client down) so
271
+ * the next {@link getListenClient} establishes a fresh connection, and emit
272
+ * `'listenerinvalidated'` so subscribed backends re-`LISTEN` and wake any
273
+ * in-flight blocking wait (which is parked on the now-dead client).
274
+ *
275
+ * Guarded so a stale error from an already-replaced client — or one racing an
276
+ * in-progress {@link close} (which owns teardown) — does not disturb the
277
+ * current client; the error is still forwarded either way.
278
+ */
279
+ handleListenClientError(client, err) {
280
+ if (this.listenClient === client && !this.closing) {
281
+ const wasStandalone = this.listenClientIsStandalone;
282
+ this.listenClient = undefined;
283
+ this.listenClientPromise = undefined;
284
+ // Drop orphaned notification listeners from in-flight waits; keep the
285
+ // 'error' listener so a follow-up error from the dying client is still
286
+ // swallowed via `emitError` rather than crashing the process.
287
+ client.removeAllListeners('notification');
288
+ try {
289
+ if (wasStandalone) {
290
+ void client.end().catch(() => undefined);
291
+ }
292
+ else {
293
+ // Destroy (rather than return) the broken pooled client.
294
+ client.release(true);
295
+ }
296
+ }
297
+ catch (_a) {
298
+ // Best-effort teardown of an already-broken client.
299
+ }
300
+ this.emit('listenerinvalidated');
301
+ }
302
+ this.emitError(err);
303
+ }
177
304
  /**
178
305
  * Truthy once {@link PostgresConnection.close} has begun.
179
306
  */
@@ -189,6 +189,19 @@ class PostgresQueueBackend extends events_1.EventEmitter {
189
189
  this.connection.on('error', err => this.emit('error', err));
190
190
  this.connection.on('ready', () => this.emit('ready'));
191
191
  this.connection.on('close', () => this.emit('close'));
192
+ // When the shared LISTEN client drops, the connection rebuilds it and
193
+ // emits `'listenerinvalidated'`. Re-issue our LISTENs on the fresh client
194
+ // and wake any in-flight wait (parked on the now-dead client) so the
195
+ // worker loop re-enters waitForJob/readEvents and re-subscribes. Only the
196
+ // connection-owning backend blocks (and thus LISTENs); non-owning
197
+ // `forQueue` siblings never do.
198
+ this.connection.on('listenerinvalidated', () => {
199
+ var _a, _b;
200
+ this.listening = false;
201
+ this.listeningEvents = false;
202
+ (_a = this.cancelWait) === null || _a === void 0 ? void 0 : _a.call(this);
203
+ (_b = this.cancelEventWait) === null || _b === void 0 ? void 0 : _b.call(this);
204
+ });
192
205
  }
193
206
  }
194
207
  // ============================================================
@@ -246,12 +259,11 @@ class PostgresQueueBackend extends events_1.EventEmitter {
246
259
  // PostgreSQL analogue of Redis `CLIENT SETNAME`. This is the long-lived
247
260
  // connection a worker / QueueEvents holds, so it appears (under this name)
248
261
  // in pg_stat_activity and is therefore discoverable by getWorkers /
249
- // getQueueEvents via getClientList.
262
+ // getQueueEvents via getClientList. The connection remembers the name and
263
+ // re-applies it whenever it has to rebuild the LISTEN client (a fresh
264
+ // connection would otherwise start unnamed, i.e. undiscoverable).
250
265
  await this.connection.waitUntilReady();
251
- const client = await this.connection.getListenClient();
252
- await client.query(`SELECT set_config('application_name', $1, false)`, [
253
- name,
254
- ]);
266
+ await this.connection.setListenClientName(name);
255
267
  }
256
268
  /**
257
269
  * PostgreSQL `LISTEN`/`NOTIFY` has no minimum block granularity, so any
@@ -260,6 +272,23 @@ class PostgresQueueBackend extends events_1.EventEmitter {
260
272
  get minimumBlockTimeout() {
261
273
  return 0.001;
262
274
  }
275
+ /**
276
+ * PostgreSQL `LISTEN`/`NOTIFY` keeps the connection open and re-arms the wait
277
+ * to the next due delayed job, so there is no cheap-reconnect reason to cap
278
+ * the block at 10s like Redis. A large ceiling lets an idle worker go quiet
279
+ * instead of re-polling every 10s (important for serverless Postgres that
280
+ * suspends when idle). 3600s stays well under the 32-bit `setTimeout` ms
281
+ * ceiling (~24.8 days) that a larger delay would overflow.
282
+ *
283
+ * This relies on a dropped LISTEN connection being *detected* (TCP keepalive)
284
+ * and rebuilt; when keepalive could not be enabled — a user-supplied
285
+ * `pg.Pool` configured without it, whose checked-out client we could not
286
+ * adjust — a silent drop would go unnoticed for the whole block, so the
287
+ * ceiling stays at the conservative Redis-like 10s instead.
288
+ */
289
+ get maximumBlockTimeout() {
290
+ return this.connection.hasListenClientKeepAlive ? 3600 : 10;
291
+ }
263
292
  forQueue(queueName, _prefix) {
264
293
  // The namespace is the connection's schema, shared by all queues, so a
265
294
  // sibling backend only needs a different queue name. BullMQ's per-queue