bullmq 6.3.0 → 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.
@@ -88,6 +88,22 @@ export declare class PostgresConnection extends EventEmitter {
88
88
  * (owned pool); `false` when it is a pooled client we must `release()`.
89
89
  */
90
90
  private listenClientIsStandalone;
91
+ /**
92
+ * Whether TCP keepalive is known to be enabled on the dedicated `LISTEN`
93
+ * connection. Only then can a silently dropped connection be detected (as a
94
+ * socket `'error'`) and rebuilt, which is the premise of the backend's large
95
+ * {@link PostgresQueueBackend.maximumBlockTimeout}; when it is not, the
96
+ * backend falls back to a conservative block ceiling instead.
97
+ */
98
+ private listenClientKeepAlive;
99
+ /**
100
+ * The last `application_name` applied to the `LISTEN` client (via
101
+ * {@link setListenClientName}). Re-applied whenever that client is rebuilt: a
102
+ * fresh connection starts unnamed, which would otherwise silently drop the
103
+ * worker / QueueEvents from `getWorkers()` / `getQueueEvents()` for the rest
104
+ * of its lifetime.
105
+ */
106
+ private listenClientName;
91
107
  constructor(connection: PostgresConnectionOptions);
92
108
  /**
93
109
  * Forwards an underlying pool / LISTEN-client error as this connection's
@@ -117,6 +133,40 @@ export declare class PostgresConnection extends EventEmitter {
117
133
  * out of it (so such pools should be sized `>= 2`).
118
134
  */
119
135
  getListenClient(): Promise<PgListenClient>;
136
+ /**
137
+ * Whether TCP keepalive is enabled on the dedicated `LISTEN` connection, so a
138
+ * silently dropped connection is detected (and the client rebuilt) instead of
139
+ * lingering unnoticed. Consulted by the backend when deciding how long a
140
+ * worker may block.
141
+ */
142
+ get hasListenClientKeepAlive(): boolean;
143
+ /**
144
+ * Sets the `application_name` of the dedicated `LISTEN` connection — the
145
+ * PostgreSQL analogue of Redis `CLIENT SETNAME` — and remembers it so it is
146
+ * re-applied to any client rebuilt after a drop.
147
+ */
148
+ setListenClientName(name: string): Promise<void>;
149
+ /**
150
+ * Re-applies the remembered {@link listenClientName} to a freshly established
151
+ * `LISTEN` client. Best-effort: discovery must never break the client.
152
+ */
153
+ private applyListenClientName;
154
+ /**
155
+ * Handles a fatal error on the dedicated `LISTEN` client.
156
+ *
157
+ * A `LISTEN` subscription lives on one specific physical connection: once that
158
+ * connection drops, the memoized client is dead and every re-`LISTEN` issued
159
+ * on it is silently lost — so a blocked worker would stop receiving NOTIFYs
160
+ * until its next poll. Invalidate the memo (and tear the dead client down) so
161
+ * the next {@link getListenClient} establishes a fresh connection, and emit
162
+ * `'listenerinvalidated'` so subscribed backends re-`LISTEN` and wake any
163
+ * in-flight blocking wait (which is parked on the now-dead client).
164
+ *
165
+ * Guarded so a stale error from an already-replaced client — or one racing an
166
+ * in-progress {@link close} (which owns teardown) — does not disturb the
167
+ * current client; the error is still forwarded either way.
168
+ */
169
+ private handleListenClientError;
120
170
  /**
121
171
  * Truthy once {@link PostgresConnection.close} has begun.
122
172
  */
@@ -24,6 +24,34 @@ function loadPgModule() {
24
24
  'already-constructed `pg.Pool` instance as the connection instead of a ' +
25
25
  'config object or connection string.');
26
26
  }
27
+ /**
28
+ * Idle time (ms) before the first TCP keepalive probe is sent on the dedicated
29
+ * `LISTEN` connection. Short enough that a silently dropped connection is
30
+ * detected in seconds instead of the OS default (typically two hours) — which,
31
+ * with a large `maximumBlockTimeout`, is what a blocked worker would otherwise
32
+ * have to wait for.
33
+ */
34
+ const LISTEN_KEEPALIVE_INITIAL_DELAY_MS = 10000;
35
+ /**
36
+ * Best-effort: turns TCP keepalive on for an already-established client's
37
+ * socket. Needed for the pooled `LISTEN` client, since a user-supplied
38
+ * `pg.Pool` may have been created without `keepAlive` and a checked-out client
39
+ * inherits that configuration. Returns whether keepalive is now enabled.
40
+ */
41
+ function enableSocketKeepAlive(client) {
42
+ var _a;
43
+ const stream = (_a = client.connection) === null || _a === void 0 ? void 0 : _a.stream;
44
+ if (!stream || typeof stream.setKeepAlive !== 'function') {
45
+ return false;
46
+ }
47
+ try {
48
+ stream.setKeepAlive(true, LISTEN_KEEPALIVE_INITIAL_DELAY_MS);
49
+ return true;
50
+ }
51
+ catch (_b) {
52
+ return false;
53
+ }
54
+ }
27
55
  /**
28
56
  * Owns the PostgreSQL connection resources for a single backend:
29
57
  *
@@ -37,6 +65,7 @@ function loadPgModule() {
37
65
  */
38
66
  export class PostgresConnection extends EventEmitter {
39
67
  constructor(connection) {
68
+ var _a;
40
69
  super();
41
70
  /**
42
71
  * `true` when {@link listenClient} is a standalone `pg.Client` we must `end()`
@@ -51,10 +80,13 @@ export class PostgresConnection extends EventEmitter {
51
80
  this.migrateOnConnect = false;
52
81
  this.pgModule = undefined;
53
82
  this.listenClientConfig = undefined;
83
+ // A user-supplied pool decides its own `keepAlive`; when it is off we try
84
+ // to enable it on the checked-out LISTEN socket (see getListenClient).
85
+ this.listenClientKeepAlive = Boolean((_a = connection.options) === null || _a === void 0 ? void 0 : _a.keepAlive);
54
86
  }
55
87
  else {
56
88
  const pg = loadPgModule();
57
- const _a = typeof connection === 'string'
89
+ const _b = typeof connection === 'string'
58
90
  ? {
59
91
  schema: undefined,
60
92
  skipVersionCheck: undefined,
@@ -62,7 +94,7 @@ export class PostgresConnection extends EventEmitter {
62
94
  skipMigrations: undefined,
63
95
  connectionString: connection,
64
96
  }
65
- : connection, { schema, skipVersionCheck, migrate, skipMigrations } = _a, poolConfig = __rest(_a, ["schema", "skipVersionCheck", "migrate", "skipMigrations"]);
97
+ : connection, { schema, skipVersionCheck, migrate, skipMigrations } = _b, poolConfig = __rest(_b, ["schema", "skipVersionCheck", "migrate", "skipMigrations"]);
66
98
  if (migrate !== undefined && skipMigrations !== undefined) {
67
99
  throw new Error('BullMQ: `migrate` and `skipMigrations` are mutually exclusive. Set only one.');
68
100
  }
@@ -85,6 +117,8 @@ export class PostgresConnection extends EventEmitter {
85
117
  // Keep the means to build a dedicated LISTEN connection on demand.
86
118
  this.pgModule = pg;
87
119
  this.listenClientConfig = resolvedConfig;
120
+ // We build the LISTEN client ourselves, always with keepAlive enabled.
121
+ this.listenClientKeepAlive = true;
88
122
  }
89
123
  // The pool emits 'error' for idle clients that drop; surface it but never
90
124
  // let it crash the process — hence the guarded {@link emitError} (a bare
@@ -153,24 +187,117 @@ export class PostgresConnection extends EventEmitter {
153
187
  if (!this.listenClientPromise) {
154
188
  this.listenClientPromise = (async () => {
155
189
  if (this.pgModule && this.listenClientConfig) {
156
- const client = new this.pgModule.Client(this.listenClientConfig);
190
+ const client = new this.pgModule.Client(Object.assign(Object.assign({}, this.listenClientConfig), {
191
+ // A LISTEN subscription is bound to one physical connection. Enable
192
+ // TCP keepalive so a silently dropped connection surfaces as a
193
+ // socket `'error'` (and is then rebuilt below) instead of going
194
+ // unnoticed until the next poll — which, with a large
195
+ // `maximumBlockTimeout`, could be up to an hour away.
196
+ keepAlive: true, keepAliveInitialDelayMillis: LISTEN_KEEPALIVE_INITIAL_DELAY_MS }));
157
197
  await client.connect();
158
- client.on('error', err => this.emitError(err));
198
+ client.on('error', err => this.handleListenClientError(client, err));
199
+ this.listenClientKeepAlive = true;
159
200
  this.listenClientIsStandalone = true;
160
201
  this.listenClient = client;
202
+ await this.applyListenClientName(client);
161
203
  return client;
162
204
  }
163
205
  else {
164
206
  const client = await this.pool.connect();
165
- client.on('error', err => this.emitError(err));
207
+ client.on('error', err => this.handleListenClientError(client, err));
208
+ // The pool is the user's, so its clients may have been created
209
+ // without `keepAlive`; enable it on this socket so a silent drop is
210
+ // still detected. If that is not possible the connection reports no
211
+ // keepalive and the backend caps its block timeout conservatively.
212
+ this.listenClientKeepAlive =
213
+ this.listenClientKeepAlive || enableSocketKeepAlive(client);
166
214
  this.listenClientIsStandalone = false;
167
215
  this.listenClient = client;
216
+ await this.applyListenClientName(client);
168
217
  return client;
169
218
  }
170
219
  })();
171
220
  }
172
221
  return this.listenClientPromise;
173
222
  }
223
+ /**
224
+ * Whether TCP keepalive is enabled on the dedicated `LISTEN` connection, so a
225
+ * silently dropped connection is detected (and the client rebuilt) instead of
226
+ * lingering unnoticed. Consulted by the backend when deciding how long a
227
+ * worker may block.
228
+ */
229
+ get hasListenClientKeepAlive() {
230
+ return this.listenClientKeepAlive;
231
+ }
232
+ /**
233
+ * Sets the `application_name` of the dedicated `LISTEN` connection — the
234
+ * PostgreSQL analogue of Redis `CLIENT SETNAME` — and remembers it so it is
235
+ * re-applied to any client rebuilt after a drop.
236
+ */
237
+ async setListenClientName(name) {
238
+ this.listenClientName = name;
239
+ const client = await this.getListenClient();
240
+ await client.query(`SELECT set_config('application_name', $1, false)`, [
241
+ name,
242
+ ]);
243
+ }
244
+ /**
245
+ * Re-applies the remembered {@link listenClientName} to a freshly established
246
+ * `LISTEN` client. Best-effort: discovery must never break the client.
247
+ */
248
+ async applyListenClientName(client) {
249
+ if (!this.listenClientName) {
250
+ return;
251
+ }
252
+ try {
253
+ await client.query(`SELECT set_config('application_name', $1, false)`, [
254
+ this.listenClientName,
255
+ ]);
256
+ }
257
+ catch (_a) {
258
+ // Discovery is best-effort; leave the connection unnamed.
259
+ }
260
+ }
261
+ /**
262
+ * Handles a fatal error on the dedicated `LISTEN` client.
263
+ *
264
+ * A `LISTEN` subscription lives on one specific physical connection: once that
265
+ * connection drops, the memoized client is dead and every re-`LISTEN` issued
266
+ * on it is silently lost — so a blocked worker would stop receiving NOTIFYs
267
+ * until its next poll. Invalidate the memo (and tear the dead client down) so
268
+ * the next {@link getListenClient} establishes a fresh connection, and emit
269
+ * `'listenerinvalidated'` so subscribed backends re-`LISTEN` and wake any
270
+ * in-flight blocking wait (which is parked on the now-dead client).
271
+ *
272
+ * Guarded so a stale error from an already-replaced client — or one racing an
273
+ * in-progress {@link close} (which owns teardown) — does not disturb the
274
+ * current client; the error is still forwarded either way.
275
+ */
276
+ handleListenClientError(client, err) {
277
+ if (this.listenClient === client && !this.closing) {
278
+ const wasStandalone = this.listenClientIsStandalone;
279
+ this.listenClient = undefined;
280
+ this.listenClientPromise = undefined;
281
+ // Drop orphaned notification listeners from in-flight waits; keep the
282
+ // 'error' listener so a follow-up error from the dying client is still
283
+ // swallowed via `emitError` rather than crashing the process.
284
+ client.removeAllListeners('notification');
285
+ try {
286
+ if (wasStandalone) {
287
+ void client.end().catch(() => undefined);
288
+ }
289
+ else {
290
+ // Destroy (rather than return) the broken pooled client.
291
+ client.release(true);
292
+ }
293
+ }
294
+ catch (_a) {
295
+ // Best-effort teardown of an already-broken client.
296
+ }
297
+ this.emit('listenerinvalidated');
298
+ }
299
+ this.emitError(err);
300
+ }
174
301
  /**
175
302
  * Truthy once {@link PostgresConnection.close} has begun.
176
303
  */
@@ -78,6 +78,21 @@ export declare class PostgresQueueBackend extends EventEmitter implements IQueue
78
78
  * positive timeout is fine; we mirror the Redis backend's smallest unit.
79
79
  */
80
80
  get minimumBlockTimeout(): number;
81
+ /**
82
+ * PostgreSQL `LISTEN`/`NOTIFY` keeps the connection open and re-arms the wait
83
+ * to the next due delayed job, so there is no cheap-reconnect reason to cap
84
+ * the block at 10s like Redis. A large ceiling lets an idle worker go quiet
85
+ * instead of re-polling every 10s (important for serverless Postgres that
86
+ * suspends when idle). 3600s stays well under the 32-bit `setTimeout` ms
87
+ * ceiling (~24.8 days) that a larger delay would overflow.
88
+ *
89
+ * This relies on a dropped LISTEN connection being *detected* (TCP keepalive)
90
+ * and rebuilt; when keepalive could not be enabled — a user-supplied
91
+ * `pg.Pool` configured without it, whose checked-out client we could not
92
+ * adjust — a silent drop would go unnoticed for the whole block, so the
93
+ * ceiling stays at the conservative Redis-like 10s instead.
94
+ */
95
+ get maximumBlockTimeout(): number;
81
96
  forQueue(queueName: string, _prefix?: string): IQueueBackend;
82
97
  /**
83
98
  * The queue's qualified name. With a schema-based namespace there is no
@@ -186,6 +186,19 @@ export class PostgresQueueBackend extends EventEmitter {
186
186
  this.connection.on('error', err => this.emit('error', err));
187
187
  this.connection.on('ready', () => this.emit('ready'));
188
188
  this.connection.on('close', () => this.emit('close'));
189
+ // When the shared LISTEN client drops, the connection rebuilds it and
190
+ // emits `'listenerinvalidated'`. Re-issue our LISTENs on the fresh client
191
+ // and wake any in-flight wait (parked on the now-dead client) so the
192
+ // worker loop re-enters waitForJob/readEvents and re-subscribes. Only the
193
+ // connection-owning backend blocks (and thus LISTENs); non-owning
194
+ // `forQueue` siblings never do.
195
+ this.connection.on('listenerinvalidated', () => {
196
+ var _a, _b;
197
+ this.listening = false;
198
+ this.listeningEvents = false;
199
+ (_a = this.cancelWait) === null || _a === void 0 ? void 0 : _a.call(this);
200
+ (_b = this.cancelEventWait) === null || _b === void 0 ? void 0 : _b.call(this);
201
+ });
189
202
  }
190
203
  }
191
204
  // ============================================================
@@ -243,12 +256,11 @@ export class PostgresQueueBackend extends EventEmitter {
243
256
  // PostgreSQL analogue of Redis `CLIENT SETNAME`. This is the long-lived
244
257
  // connection a worker / QueueEvents holds, so it appears (under this name)
245
258
  // in pg_stat_activity and is therefore discoverable by getWorkers /
246
- // getQueueEvents via getClientList.
259
+ // getQueueEvents via getClientList. The connection remembers the name and
260
+ // re-applies it whenever it has to rebuild the LISTEN client (a fresh
261
+ // connection would otherwise start unnamed, i.e. undiscoverable).
247
262
  await this.connection.waitUntilReady();
248
- const client = await this.connection.getListenClient();
249
- await client.query(`SELECT set_config('application_name', $1, false)`, [
250
- name,
251
- ]);
263
+ await this.connection.setListenClientName(name);
252
264
  }
253
265
  /**
254
266
  * PostgreSQL `LISTEN`/`NOTIFY` has no minimum block granularity, so any
@@ -257,6 +269,23 @@ export class PostgresQueueBackend extends EventEmitter {
257
269
  get minimumBlockTimeout() {
258
270
  return 0.001;
259
271
  }
272
+ /**
273
+ * PostgreSQL `LISTEN`/`NOTIFY` keeps the connection open and re-arms the wait
274
+ * to the next due delayed job, so there is no cheap-reconnect reason to cap
275
+ * the block at 10s like Redis. A large ceiling lets an idle worker go quiet
276
+ * instead of re-polling every 10s (important for serverless Postgres that
277
+ * suspends when idle). 3600s stays well under the 32-bit `setTimeout` ms
278
+ * ceiling (~24.8 days) that a larger delay would overflow.
279
+ *
280
+ * This relies on a dropped LISTEN connection being *detected* (TCP keepalive)
281
+ * and rebuilt; when keepalive could not be enabled — a user-supplied
282
+ * `pg.Pool` configured without it, whose checked-out client we could not
283
+ * adjust — a silent drop would go unnoticed for the whole block, so the
284
+ * ceiling stays at the conservative Redis-like 10s instead.
285
+ */
286
+ get maximumBlockTimeout() {
287
+ return this.connection.hasListenClientKeepAlive ? 3600 : 10;
288
+ }
260
289
  forQueue(queueName, _prefix) {
261
290
  // The namespace is the connection's schema, shared by all queues, so a
262
291
  // sibling backend only needs a different queue name. BullMQ's per-queue