pg-boss 12.28.1 → 12.30.0

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/boss.js CHANGED
@@ -10,12 +10,67 @@ const events = {
10
10
  // Default thresholds and warning messages
11
11
  const WARNINGS = {
12
12
  SLOW_QUERY: { seconds: 30, message: 'Warning: slow query. Your queues and/or database server should be reviewed' },
13
- LARGE_QUEUE: { size: 10_000, message: 'Warning: large queue backlog. Your queue should be reviewed' }
13
+ LARGE_QUEUE: { size: 10_000, message: 'Warning: large queue backlog. Your queue should be reviewed' },
14
+ // No threshold constant: the trigger is measured, not configured. See #checkVacuum.
15
+ XMIN_HORIZON: { message: 'Warning: the database transaction horizon is pinned, so completed jobs cannot be cleaned up' },
16
+ AUTOVACUUM_DISABLED: { message: 'Warning: autovacuum is disabled, so nothing is reclaiming completed jobs' },
17
+ // Deliberately weaker than XMIN_HORIZON, and the difference is the evidence behind each. That one
18
+ // asserts the horizon is pinned and that jobs cannot be reclaimed, having watched a vacuum run and
19
+ // reclaim nothing across two passes. This one has a stopwatch and nothing else. The aggregate does
20
+ // pin the horizon while it runs - that much is definitional - but whether any reclamation was
21
+ // actually lost to it is not measured here, so this reports the exposure and the action taken and
22
+ // leaves the diagnosis to XMIN_HORIZON, which will fire on its own if the harm is real.
23
+ MONITOR_BACKOFF: { message: 'Warning: queue monitoring spent long enough scanning the job table to risk holding back autovacuum, so the next stats refresh is deferred' }
14
24
  };
15
25
  const WARNING_TYPES = {
16
26
  SLOW_QUERY: 'slow_query',
17
- QUEUE_BACKLOG: 'queue_backlog'
27
+ QUEUE_BACKLOG: 'queue_backlog',
28
+ INDEX_BLOAT: 'index_bloat',
29
+ XMIN_HORIZON: 'xmin_horizon',
30
+ AUTOVACUUM_DISABLED: 'autovacuum_disabled',
31
+ MONITOR_BACKOFF: 'monitor_backoff'
18
32
  };
33
+ // Which holder class each source in the horizon query represents, phrased so the warning names
34
+ // something an operator can go and look at.
35
+ const XMIN_HOLDERS = {
36
+ backends: 'a backend holding an open transaction',
37
+ slots: 'a replication slot',
38
+ slotsCatalog: 'a replication slot (catalog xmin)',
39
+ standbys: 'a standby with hot_standby_feedback enabled',
40
+ prepared: 'a prepared transaction'
41
+ };
42
+ // Name the holder as specifically as the catalog allowed. Falls back to the holder class when the
43
+ // source is not a backend (a replication slot has no pid to report) or when no backend row came
44
+ // back, so the warning never loses the description it had before.
45
+ function describeXminHolder(source, row) {
46
+ const holder = source === 'backends' ? row.backendHolder : null;
47
+ if (!holder)
48
+ return XMIN_HOLDERS[source];
49
+ const app = holder.applicationName || null;
50
+ const where = [
51
+ app && `application_name '${app}'`,
52
+ holder.userName && `role ${holder.userName}`,
53
+ `pid ${holder.pid}`
54
+ ].filter(Boolean).join(', ');
55
+ const open = holder.xactSeconds === null || holder.xactSeconds === undefined
56
+ ? 'for an unreadable length of time'
57
+ : `for ${holder.xactSeconds}s`;
58
+ // Three outcomes, not two, because an empty application_name cannot be classified either way and
59
+ // saying "another application" would be asserting something unmeasured. Comparison is against
60
+ // this connection's own value rather than the literal 'pgboss': an adapter-supplied pool carries
61
+ // whatever the host app set, so "same application_name as us" is the strongest true claim.
62
+ const who = !app
63
+ ? 'a backend with no application_name'
64
+ : app === row.selfApplicationName
65
+ ? "this application's own connection"
66
+ : 'another application';
67
+ return `${who} (${where}) has held a transaction open ${open}`;
68
+ }
69
+ // SQLSTATE 25001 (active_sql_transaction): "REINDEX CONCURRENTLY cannot run inside a transaction
70
+ // block". Raised when a user-supplied adapter wraps executeSql in a transaction — a property of the
71
+ // adapter, not of this pass, so it disables rebuilds for the life of the instance rather than
72
+ // retrying every interval.
73
+ const IN_TRANSACTION_ERROR = '25001';
19
74
  class Boss extends EventEmitter {
20
75
  #stopped;
21
76
  #stopping;
@@ -26,6 +81,33 @@ class Boss extends EventEmitter {
26
81
  #manager;
27
82
  #slowQuerySeconds;
28
83
  #largeQueueSize;
84
+ // Set once a rebuild proves impossible on this connection (see IN_TRANSACTION_ERROR). Detection
85
+ // keeps running; only the DDL is abandoned.
86
+ #reindexUnavailable = null;
87
+ // Warn once per bloated index rather than on every pass. An index leaves the set as soon as it
88
+ // stops qualifying — rebuilt, dropped, or refilled — so a later episode warns again.
89
+ #warnedBloat = new Set();
90
+ // Local rate limit for passes that only report bloat, which deliberately leave the shared interval
91
+ // claim to whichever instance can act on it.
92
+ #detectOnly = 0;
93
+ // Latched while a job table is over its autovacuum budget, so a sustained pin warns once rather
94
+ // than every pass. Cleared when every table is back under budget, so a later episode warns again.
95
+ #warnedXminHorizon = false;
96
+ // Same latching for the sibling diagnosis: garbage growing on a table nothing vacuums at all.
97
+ #warnedAutovacuumDisabled = false;
98
+ // Per job table: the dead-tuple count and vacuum timestamp last seen while the table was over its
99
+ // autovacuum budget. Two observations are what turn "there is garbage" into "a vacuum ran and
100
+ // reclaimed none of it" - see #checkXminHorizon.
101
+ #garbageEvidence = new Map();
102
+ // Sources the connected role could not read. Narrowed once on first failure rather than retried
103
+ // every pass; an empty-but-unreadable source must never be reported as healthy.
104
+ #xminHorizonSources = null;
105
+ // Seconds this supervise pass held the MVCC horizon inside the queue-stats aggregate, as the
106
+ // server measured it, summed across every table and every 100-name chunk. The sum, not the worst
107
+ // single query: chunking and partitioned queues both multiply the aggregate within one pass (each
108
+ // chunk re-reads the whole heap - measured at 384,632 buffers for 2 of 20 queue names), and it is
109
+ // the total contiguous pin that starves autovacuum, not any one scan.
110
+ #statsElapsedSeconds = 0;
29
111
  events = events;
30
112
  constructor(db, manager, config) {
31
113
  super();
@@ -124,7 +206,7 @@ class Boss extends EventEmitter {
124
206
  : plans.dropOldQueueStatsPartitions(this.#config.schema, this.#config.queueStatRetentionDays);
125
207
  await this.#executeQuery(sql);
126
208
  }
127
- async supervise(value) {
209
+ async supervise(value, options) {
128
210
  let queues;
129
211
  if (Array.isArray(value)) {
130
212
  queues = value;
@@ -161,8 +243,17 @@ class Boss extends EventEmitter {
161
243
  }
162
244
  if (this.#stopping)
163
245
  return;
246
+ // Immediately after the aggregates it measures, not at the head of the next pass. Deferring the
247
+ // write left a whole supervise interval between a slow aggregate finishing and the gate closing
248
+ // behind it, and a getQueueStats({ force: true }) landing in that window ran exactly the scan
249
+ // the backoff was about to prevent.
250
+ await this.#applyMonitorBackoff();
251
+ await this.#checkVacuum();
164
252
  await this.#maintainWarnings();
165
253
  await this.#maintainQueueStats();
254
+ // Last in the pass: a rebuild is DDL that can run for seconds, so nothing time-sensitive
255
+ // (expiry, deletion, stats) should ever queue behind it.
256
+ await this.#reindex(Object.keys(queueGroups), options);
166
257
  }
167
258
  async #monitor(table, names) {
168
259
  if (this.#stopping)
@@ -173,21 +264,44 @@ class Boss extends EventEmitter {
173
264
  return;
174
265
  if (rows.length) {
175
266
  const queues = rows.map((q) => q.name);
176
- const cacheStatsSql = plans.cacheQueueStats(this.#config.schema, table, queues, this.#config.noAdvisoryLocks);
177
- const { rows: rowsCacheStats } = await this.#executeQuery(cacheStatsSql);
178
- if (this.#config.persistQueueStats) {
179
- const insertSql = plans.insertQueueStats(this.#config.schema, queues, this.#config.noAdvisoryLocks);
180
- await this.#executeQuery(insertSql);
267
+ // The vacuum-safety backoff defers the stats aggregate, not the whole pass. Job expiry and
268
+ // heartbeat failure below are narrow indexed updates that pin nothing, and gating them on the
269
+ // horizon would turn a vacuum-safety valve into a job-expiry outage lasting two naptimes at
270
+ // minimum. See the note on plans.trySetQueueMonitorTime. The flag is a property of the
271
+ // statement, not of a queue, so every row carries the same value.
272
+ const refreshStats = rows[0].refreshStats !== false;
273
+ if (refreshStats) {
274
+ const cacheStatsSql = plans.cacheQueueStats(this.#config.schema, table, queues, this.#config.noAdvisoryLocks);
275
+ // The pin this pass cost, taken from the server's own clock (see the pinSeconds column in
276
+ // cacheQueueStats) and not from a stopwatch around the call - that would count pool wait,
277
+ // network and event-loop lag, none of which hold the horizon. The client measurement stays as
278
+ // a fallback for a backend or adapter that returns no rows to read it from.
279
+ const statsStarted = Date.now();
280
+ const { rows: rowsCacheStats } = await this.#executeQuery(cacheStatsSql);
281
+ const pinned = rowsCacheStats.reduce((max, row) => Math.max(max, Number(row.pinSeconds) || 0), 0);
282
+ // Only when the aggregate actually ran. An empty result means another instance holds the
283
+ // stats try-lock and this statement returned without scanning anything, so the elapsed
284
+ // client time is pool and network latency - exactly the measurement the server-side
285
+ // pinSeconds column exists to avoid backing off on.
286
+ if (rowsCacheStats.length) {
287
+ this.#statsElapsedSeconds += pinned || (Date.now() - statsStarted) / 1000;
288
+ }
289
+ if (this.#config.persistQueueStats) {
290
+ const insertSql = plans.insertQueueStats(this.#config.schema, queues, this.#config.noAdvisoryLocks);
291
+ await this.#executeQuery(insertSql);
292
+ }
293
+ if (this.#stopping)
294
+ return;
295
+ // Coerce with Number(): CockroachDB returns these integer columns as strings, so a bare `>`
296
+ // would compare lexicographically ("100" > "9" === false) and silently miss the backlog. On
297
+ // standard Postgres these are already numbers, so Number() is a no-op.
298
+ const warnings = rowsCacheStats.filter(i => Number(i.queuedCount) > (Number(i.warningQueueSize) || this.#largeQueueSize));
299
+ for (const warning of warnings) {
300
+ await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.QUEUE_BACKLOG, WARNINGS.LARGE_QUEUE.message, warning);
301
+ }
181
302
  }
182
303
  if (this.#stopping)
183
304
  return;
184
- // Coerce with Number(): CockroachDB returns these integer columns as strings, so a bare `>`
185
- // would compare lexicographically ("100" > "9" === false) and silently miss the backlog. On
186
- // standard Postgres these are already numbers, so Number() is a no-op.
187
- const warnings = rowsCacheStats.filter(i => Number(i.queuedCount) > (Number(i.warningQueueSize) || this.#largeQueueSize));
188
- for (const warning of warnings) {
189
- await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.QUEUE_BACKLOG, WARNINGS.LARGE_QUEUE.message, warning);
190
- }
191
305
  // CockroachDB rejects the multi-mutation failJobs() CTE these use, so under noMultiMutationCte
192
306
  // route expiry through the manager's split select/delete/re-insert variants instead.
193
307
  if (this.#config.noMultiMutationCte) {
@@ -223,5 +337,477 @@ class Boss extends EventEmitter {
223
337
  await this.#executeQuery(depSql);
224
338
  }
225
339
  }
340
+ /**
341
+ * Defer the next queue-stats aggregate when this pass spent long enough inside it to threaten
342
+ * autovacuum, and tell the operator that it happened.
343
+ *
344
+ * The aggregate in cacheQueueStats is a whole-heap scan - the queue-name filter does not reduce
345
+ * the I/O, only the number of rows fed to the aggregate transitions - and for its whole duration
346
+ * it advertises a snapshot Postgres will not vacuum past. Measured on a 3 GB / 10M-row job table:
347
+ * 0.19 s at 1M rows, 2.1 s with parallel workers and 3.6 s without at 10M, and 24-75 s for the
348
+ * same 3 GB read cold from network storage. Against that, a 200,000-row delete stayed
349
+ * "dead but not yet removable" for exactly as long as one such snapshot was held open, and was
350
+ * removed by the next vacuum after it closed.
351
+ *
352
+ * What is measured is the server's own transaction duration, not the latency of the call: a pool
353
+ * with no free connection makes executeSql slow without pinning anything (a 1 ms transaction timed
354
+ * at 1.97 s behind one busy connection), and backing off on that would defer monitoring for pool
355
+ * contention and then tell the operator to go and shrink their job table.
356
+ *
357
+ * So the danger is not the aggregate; it is the aggregate's duty cycle. Three things push it up
358
+ * without anyone changing a default - a slow enough scan that a pass outruns the interval, the
359
+ * 100-name chunking that makes one pass re-read the heap once per chunk, and one aggregate per
360
+ * table on a partitioned deployment - and one thing pushes it up deliberately, which is tuning
361
+ * monitorIntervalSeconds down. All four land in the same place, and #statsElapsedSeconds measures
362
+ * all four the same way.
363
+ *
364
+ * The threshold and the length both come from the server's own autovacuum_naptime rather than
365
+ * from a constant here; see plans.setMonitorBackoff for why the free window has to be longer than
366
+ * a naptime and why two of them is the right number. The statement returns a row only when it
367
+ * engaged, so this warns exactly when monitoring is actually being held back.
368
+ *
369
+ * PostgreSQL-only, and off with the rest of the vacuum subsystem: distributed backends have no
370
+ * autovacuum_naptime to read and reclaim on their own schedule, and an operator who set
371
+ * monitorVacuum: false has taken vacuum health into their own hands.
372
+ */
373
+ async #applyMonitorBackoff() {
374
+ const elapsed = this.#config.__test__monitor_stats_seconds ?? this.#statsElapsedSeconds;
375
+ this.#statsElapsedSeconds = 0;
376
+ if (!elapsed)
377
+ return;
378
+ if (this.#config.monitorVacuum === false || this.#config.noMonitorVacuum)
379
+ return;
380
+ const { rows } = await this.#executeQuery(plans.setMonitorBackoff(this.#config.schema, elapsed));
381
+ const engaged = rows.at(0);
382
+ if (!engaged)
383
+ return;
384
+ await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.MONITOR_BACKOFF, `${WARNINGS.MONITOR_BACKOFF.message}: ${elapsed.toFixed(1)}s of aggregate against an ` +
385
+ `autovacuum_naptime of ${Number(engaged.naptimeSeconds)}s. ` +
386
+ 'Shrink the job table (retention, deleteAfterSeconds) or partition its busiest queues.', {
387
+ elapsedSeconds: elapsed,
388
+ naptimeSeconds: Number(engaged.naptimeSeconds),
389
+ backoffSeconds: Number(engaged.backoffSeconds),
390
+ backoffUntil: engaged.backoffUntil
391
+ });
392
+ }
393
+ /**
394
+ * Warn when the queues are producing garbage that nothing is reclaiming. Every documented
395
+ * Postgres-queue collapse has this as its precondition, and until now pg-boss reported only the
396
+ * symptom (`queue_backlog`) and never the cause: a backlog caused by too few workers and one
397
+ * caused by unreclaimed garbage look identical from the queue's own counters, and have opposite
398
+ * fixes.
399
+ *
400
+ * One measurement, two diagnoses, because the fixes are completely different:
401
+ *
402
+ * `xmin_horizon` vacuum runs and reclaims nothing - something is pinning the MVCC
403
+ * horizon, and the fix is to find and release the holder.
404
+ * `autovacuum_disabled` nothing runs at all - autovacuum is turned off on the table, and the
405
+ * fix is to turn it back on or vacuum on a schedule that keeps up.
406
+ *
407
+ * The trigger is the harm itself, read from `pg_stat_user_tables`, not a transaction-count proxy.
408
+ * A measured comparison of the two (scripts/xmin-horizon-experiment.js) is what settled this: the
409
+ * transaction count that corresponds to a given amount of unreclaimable garbage moves by ~9x with
410
+ * producer batching alone, and without bound with unrelated traffic on the same cluster, because
411
+ * `age()` is cluster-global. No constant survives that. Dead tuples do not move: the same 40,000
412
+ * jobs left the same 120,000 unreclaimable rows across both producer shapes.
413
+ *
414
+ * Both diagnoses share the first condition - a job table past the point Postgres itself would
415
+ * vacuum it (`autovacuum_vacuum_threshold + scale_factor * live`, per-table storage parameters
416
+ * honoured) - and then split on what two consecutive observations show:
417
+ *
418
+ * a vacuum ran and the dead-tuple count did not fall -> the horizon is pinned
419
+ * no vacuum ran and the dead-tuple count grew -> nothing is vacuuming this table
420
+ *
421
+ * Either way it takes two passes to establish, so the first pass after a problem starts never
422
+ * warns. That is the intended cost of not guessing: one supervise interval of delay buys a signal
423
+ * that means what it says. It is also what keeps an operator who has turned autovacuum off
424
+ * deliberately and vacuums on their own schedule quiet - their manual vacuum both moves the
425
+ * timestamp and drops the count, so neither branch matches.
426
+ *
427
+ * PostgreSQL-only: CockroachDB and YugabyteDB reclaim on their engine's own schedule rather than
428
+ * from the oldest live snapshot, and their profiles set noMonitorVacuum.
429
+ */
430
+ async #checkVacuum() {
431
+ if (this.#stopping)
432
+ return;
433
+ if (this.#config.monitorVacuum === false || this.#config.noMonitorVacuum)
434
+ return;
435
+ const { rows } = await this.#executeQuery(plans.getJobTableGarbage(this.#config.schema));
436
+ if (this.#stopping)
437
+ return;
438
+ // Vacuum ran and reclaimed nothing, versus nothing ran at all.
439
+ const stuck = [];
440
+ const unvacuumed = [];
441
+ // Tracked separately so one table sitting permanently over budget with autovacuum off cannot
442
+ // pin the other diagnosis's latch and suppress a later, unrelated episode.
443
+ let vacuumedOverBudget = false;
444
+ let disabledOverBudget = false;
445
+ // Partitioned queues come and go, so drop evidence for tables the query no longer returns
446
+ // rather than holding a row per table this instance has ever seen.
447
+ const present = new Set(rows.map(row => row.name));
448
+ for (const name of this.#garbageEvidence.keys()) {
449
+ if (!present.has(name))
450
+ this.#garbageEvidence.delete(name);
451
+ }
452
+ for (const row of rows) {
453
+ const name = row.name;
454
+ const liveTuples = Number(row.liveTuples);
455
+ const deadTuples = Number(row.deadTuples);
456
+ const budget = Number(row.threshold) + Number(row.scaleFactor) * liveTuples;
457
+ const disabled = row.autovacuumEnabled === false;
458
+ // Compared only against the previous observation of the same table, so any monotonic clock
459
+ // works; a table never vacuumed reads as 0 and stays there, which is exactly the state the
460
+ // autovacuum_disabled branch is looking for.
461
+ const lastVacuum = row.lastVacuum ? new Date(row.lastVacuum).getTime() : 0;
462
+ if (deadTuples <= budget) {
463
+ this.#garbageEvidence.delete(name);
464
+ continue;
465
+ }
466
+ if (disabled) {
467
+ disabledOverBudget = true;
468
+ }
469
+ else {
470
+ vacuumedOverBudget = true;
471
+ }
472
+ const previous = this.#garbageEvidence.get(name);
473
+ this.#garbageEvidence.set(name, { deadTuples, lastVacuum });
474
+ if (!previous)
475
+ continue;
476
+ const garbage = {
477
+ name,
478
+ liveTuples,
479
+ deadTuples,
480
+ budget,
481
+ // Null rather than 0 when the table has never been vacuumed at all - "never" and "just now"
482
+ // are opposite readings and must not share a value.
483
+ vacuumAgeSeconds: row.vacuumAgeSeconds === null ? null : Number(row.vacuumAgeSeconds),
484
+ lastVacuum
485
+ };
486
+ if (disabled) {
487
+ // Nothing ran between the two observations and the pile grew.
488
+ if (lastVacuum === previous.lastVacuum && deadTuples > previous.deadTuples) {
489
+ unvacuumed.push(garbage);
490
+ }
491
+ }
492
+ else if (lastVacuum > previous.lastVacuum && deadTuples >= previous.deadTuples) {
493
+ // A vacuum ran between the two observations and left at least as much garbage as it found.
494
+ stuck.push(garbage);
495
+ }
496
+ }
497
+ if (!vacuumedOverBudget)
498
+ this.#warnedXminHorizon = false;
499
+ if (!disabledOverBudget)
500
+ this.#warnedAutovacuumDisabled = false;
501
+ await this.#warnAutovacuumDisabled(unvacuumed);
502
+ await this.#warnXminHorizon(stuck);
503
+ }
504
+ /**
505
+ * Garbage piling up on a table nothing vacuums. No horizon lookup: there is no vacuum here that
506
+ * could have failed, so there is no holder to blame, and naming one would send the operator after
507
+ * the wrong thing entirely. The fix is autovacuum, not a stuck transaction.
508
+ */
509
+ async #warnAutovacuumDisabled(unvacuumed) {
510
+ if (!unvacuumed.length || this.#warnedAutovacuumDisabled || this.#stopping)
511
+ return;
512
+ this.#warnedAutovacuumDisabled = true;
513
+ const worst = unvacuumed.reduce((a, b) => (b.deadTuples > a.deadTuples ? b : a));
514
+ const since = worst.vacuumAgeSeconds === null
515
+ ? 'has never been vacuumed'
516
+ : `was last vacuumed ${worst.vacuumAgeSeconds}s ago`;
517
+ await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.AUTOVACUUM_DISABLED, `${WARNINGS.AUTOVACUUM_DISABLED.message}: ${worst.name} has autovacuum_enabled = false, holds ` +
518
+ `${worst.deadTuples} dead rows and is still growing, and ${since} ` +
519
+ `(Postgres would vacuum this table at ${Math.round(worst.budget)})`, {
520
+ table: worst.name,
521
+ tables: unvacuumed.map(t => t.name),
522
+ liveTuples: worst.liveTuples,
523
+ deadTuples: worst.deadTuples,
524
+ budget: Math.round(worst.budget),
525
+ vacuumAgeSeconds: worst.vacuumAgeSeconds
526
+ });
527
+ }
528
+ /** Garbage that survived a vacuum, attributed to whatever is holding the horizon back. */
529
+ async #warnXminHorizon(stuck) {
530
+ if (!stuck.length || this.#warnedXminHorizon || this.#stopping)
531
+ return;
532
+ // The vacuum a holder has to predate is the most recent one that failed, so the newest wins.
533
+ const lastVacuum = new Date(Math.max(...stuck.map(t => t.lastVacuum)));
534
+ const horizon = await this.#readXminHorizon(lastVacuum);
535
+ if (!horizon || this.#stopping)
536
+ return;
537
+ const holder = this.#attributeXminHorizon(horizon.row);
538
+ // Garbage that survived a vacuum with no holder old enough to explain it is a real problem, but
539
+ // not this one - staying quiet is what keeps the warning worth acting on.
540
+ if (!holder)
541
+ return;
542
+ this.#warnedXminHorizon = true;
543
+ const worst = stuck.reduce((a, b) => (b.deadTuples > a.deadTuples ? b : a));
544
+ const seconds = Number(horizon.row.oldestTransactionSeconds);
545
+ const backend = holder.source === 'backends' ? horizon.row.backendHolder ?? null : null;
546
+ const opaque = Number(horizon.row.opaqueBackends) || 0;
547
+ // Only worth saying when the holder could not be named: with a name in hand the operator has
548
+ // what they need, and the grant is beside the point.
549
+ const opaqueNote = opaque && !backend
550
+ ? ` ${opaque} backend(s) could not be inspected by this role; GRANT pg_read_all_stats to name them.`
551
+ : '';
552
+ await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.XMIN_HORIZON, `${WARNINGS.XMIN_HORIZON.message}: ${describeXminHolder(holder.source, horizon.row)}, ` +
553
+ `holding it ${holder.age} transactions back. ` +
554
+ `${worst.name} has ${worst.deadTuples} dead rows that a vacuum ${worst.vacuumAgeSeconds}s ago could not reclaim ` +
555
+ `(Postgres vacuums this table at ${Math.round(worst.budget)}).${opaqueNote}`, {
556
+ source: holder.source,
557
+ holder: describeXminHolder(holder.source, horizon.row),
558
+ holderClass: XMIN_HOLDERS[holder.source],
559
+ // Null unless the holder is a backend this role could read a row for. `self` says whether it
560
+ // shares this connection's application_name — pg-boss pinning its own horizon and an
561
+ // external reporting tool pinning it have opposite fixes.
562
+ holderPid: backend?.pid ?? null,
563
+ holderApplicationName: backend?.applicationName ?? null,
564
+ holderUserName: backend?.userName ?? null,
565
+ holderState: backend?.state ?? null,
566
+ holderTransactionSeconds: backend?.xactSeconds ?? null,
567
+ self: backend ? backend.applicationName === horizon.row.selfApplicationName : null,
568
+ // Backends whose transaction this role is not allowed to time. Non-zero means the picture is
569
+ // partial, whether or not a holder was named.
570
+ opaqueBackends: opaque,
571
+ transactions: holder.age,
572
+ table: worst.name,
573
+ liveTuples: worst.liveTuples,
574
+ deadTuples: worst.deadTuples,
575
+ budget: Math.round(worst.budget),
576
+ vacuumAgeSeconds: worst.vacuumAgeSeconds,
577
+ tables: stuck.map(t => t.name),
578
+ oldestTransactionSeconds: Number.isFinite(seconds) ? seconds : null,
579
+ // Named so a partial answer is never mistaken for a clean one.
580
+ unreadableSources: horizon.unreadable
581
+ });
582
+ }
583
+ /**
584
+ * The widest holder in the row. Every source the query returns has already been qualified — the
585
+ * backends column is filtered server-side to transactions that predate the failed vacuum, and a
586
+ * slot, standby or prepared transaction advertises an xmin only while something is genuinely
587
+ * stuck — so this is a straight maximum. The horizon is pinned to the oldest of them, which makes
588
+ * the widest the one worth naming.
589
+ */
590
+ #attributeXminHorizon(row) {
591
+ let worst = null;
592
+ // Iterates the known holder classes, not the row's columns: the row also carries
593
+ // oldestTransactionSeconds, which is informational and would otherwise read as a holder named
594
+ // after a column with an age of 0.
595
+ for (const source of Object.keys(XMIN_HOLDERS)) {
596
+ const value = row[source];
597
+ const age = value === undefined || value === null ? NaN : Number(value);
598
+ if (Number.isFinite(age) && (!worst || age > worst.age)) {
599
+ worst = { source, age };
600
+ }
601
+ }
602
+ return worst;
603
+ }
604
+ /**
605
+ * Read the horizon, narrowing to the sources this role can actually read. Called only when a
606
+ * table already shows unreclaimable garbage, so an installation with a healthy horizon never
607
+ * pays for it.
608
+ */
609
+ async #readXminHorizon(lastVacuum) {
610
+ const attempted = this.#xminHorizonSources ?? plans.XMIN_HORIZON_QUERY_SOURCES;
611
+ // Narrowing has already found that this role can read none of them, and the check said so once.
612
+ // Without this the empty list would be handed back to getXminHorizon, which cannot build a
613
+ // statement with no columns, and every later pass would re-fail, re-probe and re-warn.
614
+ if (!attempted.length)
615
+ return null;
616
+ try {
617
+ const { rows } = await this.#executeQuery(plans.getXminHorizon(lastVacuum, attempted));
618
+ const unreadable = plans.XMIN_HORIZON_QUERY_SOURCES.filter(name => !attempted.includes(name));
619
+ return { row: (rows[0] ?? {}), unreadable };
620
+ }
621
+ catch (err) {
622
+ // A role that cannot read one of the catalogs fails the whole statement. Narrow to the
623
+ // sources that do work rather than losing the check entirely - but remember what was dropped,
624
+ // because "no rows" from an unreadable view must not read as "horizon is healthy".
625
+ const readable = await this.#narrowXminHorizonSources(attempted, lastVacuum);
626
+ this.#xminHorizonSources = readable;
627
+ if (!readable.length) {
628
+ this.emit(events.warning, {
629
+ message: 'Unable to read the transaction horizon; the xmin_horizon check is disabled for this instance.',
630
+ data: { type: WARNING_TYPES.XMIN_HORIZON, error: err?.message }
631
+ });
632
+ }
633
+ return null;
634
+ }
635
+ }
636
+ // Probe each source alone to find which ones this role can actually read. Runs only after the
637
+ // combined query has already failed, so the cost is paid once per instance, not per pass.
638
+ async #narrowXminHorizonSources(attempted, lastVacuum) {
639
+ const readable = [];
640
+ for (const source of attempted) {
641
+ try {
642
+ await this.#executeQuery(plans.getXminHorizon(lastVacuum, [source]));
643
+ readable.push(source);
644
+ }
645
+ catch {
646
+ // Unreadable for this role; reported alongside the warning as unreadableSources.
647
+ }
648
+ }
649
+ return readable;
650
+ }
651
+ // DDL runs outside the slow-query timer. A REINDEX is expected to take seconds — routing it
652
+ // through #executeQuery would emit a bogus slow_query warning on every rebuild.
653
+ async #executeDdl(sql) {
654
+ return unwrapSQLResult(await this.#db.executeSql(sql));
655
+ }
656
+ #resolveReindexOptions(options) {
657
+ const setting = options && 'reindex' in options ? options.reindex : this.#config.reindex;
658
+ if (setting === false)
659
+ return null;
660
+ if (setting === true || setting === undefined)
661
+ return {};
662
+ return setting;
663
+ }
664
+ /**
665
+ * Reports bloated job indexes and, unless disabled, rebuilds them with REINDEX INDEX
666
+ * CONCURRENTLY. See the notes in plans.ts for why the trigger is index density rather than
667
+ * elapsed time.
668
+ *
669
+ * Detection still runs where the rebuild cannot: a role that does not own the indexes, an adapter
670
+ * that wraps queries in a transaction, and `reindex: false` all still produce the `index_bloat`
671
+ * warning and can act on getReindexCommands(). The one exception is a backend that stores data
672
+ * outside PostgreSQL's heap — see the noReindex gate below.
673
+ */
674
+ async #reindex(tables, options) {
675
+ if (this.#stopping)
676
+ return;
677
+ // Skipped whole, detection included, on engines with no btree page bloat to find. Verified on
678
+ // CockroachDB v26.2 and YugabyteDB 2025.2: CockroachDB has no pg_relation_size() at all and
679
+ // rejects `reltuples / relpages` outright ("unsupported binary operator: <float4> / <int4>"),
680
+ // so running the check would throw once per interval; YugabyteDB answers but reports relpages
681
+ // and pg_relation_size as 0 for every relation, so nothing could ever match. Both store data in
682
+ // an LSM that compacts on its own, and both reject REINDEX in either form — CockroachDB with
683
+ // the hint "CockroachDB does not require reindexing."
684
+ if (this.#config.noReindex)
685
+ return;
686
+ const resolved = this.#resolveReindexOptions(options);
687
+ const force = !!resolved?.force;
688
+ const rebuilding = resolved !== null && !this.#reindexUnavailable;
689
+ // An explicit force is a request to run now; everything else waits for an interval.
690
+ //
691
+ // Which interval depends on whether this instance can do the work. The shared claim exists so
692
+ // exactly one instance in the cluster rebuilds per window — an instance that is only ever going
693
+ // to report bloat has no business taking it, or a peer configured to rebuild would find the
694
+ // window gone and skip the rebuild for a whole day. Detection-only passes throttle themselves
695
+ // locally instead, on the same interval.
696
+ if (!force) {
697
+ if (rebuilding) {
698
+ const claim = plans.trySetReindexTime(this.#config.schema, this.#config.reindexIntervalSeconds);
699
+ const { rows } = await this.#executeQuery(claim);
700
+ if (!rows.length)
701
+ return;
702
+ }
703
+ else {
704
+ if (Date.now() < this.#detectOnly)
705
+ return;
706
+ this.#detectOnly = Date.now() + this.#config.reindexIntervalSeconds * 1000;
707
+ }
708
+ }
709
+ if (this.#stopping)
710
+ return;
711
+ const scope = tables.length ? tables : undefined;
712
+ const detectSql = plans.getBloatedIndexes(this.#config.schema, scope, resolved ?? undefined);
713
+ const { rows: bloated } = await this.#executeQuery(detectSql);
714
+ let targets = [];
715
+ if (rebuilding) {
716
+ const maxIndexBytes = resolved.maxIndexBytes ?? plans.REINDEX_DEFAULTS.maxIndexBytes;
717
+ const candidates = force
718
+ ? (await this.#executeQuery(plans.getJobIndexes(this.#config.schema, scope))).rows
719
+ : bloated;
720
+ targets = candidates.filter((i) => i.owned && Number(i.bytes) <= maxIndexBytes);
721
+ if (targets.length) {
722
+ await this.#dropReindexLeftovers(scope);
723
+ }
724
+ }
725
+ const rebuilt = new Set();
726
+ const failed = new Map();
727
+ for (const target of targets) {
728
+ if (this.#stopping)
729
+ return;
730
+ try {
731
+ await this.#executeDdl(plans.reindexIndex(this.#config.schema, target.name));
732
+ rebuilt.add(target.name);
733
+ }
734
+ catch (err) {
735
+ const code = err?.code;
736
+ if (code === IN_TRANSACTION_ERROR) {
737
+ this.#reindexUnavailable = err.message;
738
+ failed.set(target.name, this.#reindexUnavailable);
739
+ // Every remaining index would fail identically — the transaction wrapper is a property of
740
+ // the adapter, not of this index.
741
+ break;
742
+ }
743
+ failed.set(target.name, err.message);
744
+ }
745
+ }
746
+ await this.#warnIndexBloat(bloated, rebuilt, failed, rebuilding);
747
+ }
748
+ // Invalid `*_ccnew` stubs from an interrupted REINDEX CONCURRENTLY. Best effort: a failure here
749
+ // must not stop the rebuilds, since the retry's own IF EXISTS handles the common case anyway.
750
+ async #dropReindexLeftovers(tables) {
751
+ try {
752
+ const { rows } = await this.#executeQuery(plans.getReindexLeftovers(this.#config.schema, tables, this.#config.noIndexProgressView));
753
+ for (const leftover of rows) {
754
+ if (this.#stopping)
755
+ return;
756
+ await this.#executeDdl(plans.dropIndexConcurrently(this.#config.schema, leftover.name));
757
+ }
758
+ }
759
+ catch (err) {
760
+ this.emit(events.error, err);
761
+ }
762
+ }
763
+ async #warnIndexBloat(bloated, rebuilt, failed, rebuilding) {
764
+ const stillBloated = new Set();
765
+ for (const index of bloated) {
766
+ // A rebuilt index is no longer a standing condition, and re-warning about it next episode is
767
+ // correct, so it never enters the warned set.
768
+ if (rebuilt.has(index.name))
769
+ continue;
770
+ stillBloated.add(index.name);
771
+ if (this.#warnedBloat.has(index.name))
772
+ continue;
773
+ // Ownership first: an index the role cannot touch was never a candidate, so it has no entry in
774
+ // `failed` no matter why the pass stopped. #reindexUnavailable comes next and covers the
775
+ // indexes the 25001 giveup skipped without attempting — they are neither failed nor rebuilt,
776
+ // and reporting a size cap they are nowhere near would point at the wrong knob.
777
+ const reason = failed.get(index.name) ??
778
+ (!index.owned
779
+ ? 'the connected role does not own the index'
780
+ : this.#reindexUnavailable ??
781
+ (!rebuilding
782
+ ? 'automatic reindexing is disabled'
783
+ : 'the index is larger than maxIndexBytes'));
784
+ await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.INDEX_BLOAT, `Warning: index "${index.name}" is bloated (${Math.round(Number(index.bytes) / 1024 / 1024)} MB across ${index.pages} pages for ~${index.entries} live entries) and was not rebuilt: ${reason}. See getReindexCommands()`, index);
785
+ }
786
+ this.#warnedBloat = stillBloated;
787
+ }
788
+ /**
789
+ * The REINDEX statements this instance would run, for installations where it cannot run them
790
+ * itself. Unlike the background pass this applies no ownership filter and no size cap unless one
791
+ * is passed — the commands are for an operator, who may run them as a different role.
792
+ */
793
+ async getReindexCommands(options) {
794
+ // The catalog query reads pg_class.relpages and pg_relation_size(), which the heap-less engines
795
+ // either reject outright or answer with zeroes — same gate as #reindex, and there is nothing to
796
+ // rebuild on them anyway.
797
+ if (this.#config.noReindex)
798
+ return [];
799
+ const schema = this.#config.schema;
800
+ const sql = options?.force
801
+ ? plans.getJobIndexes(schema)
802
+ : plans.getBloatedIndexes(schema, undefined, options);
803
+ const { rows } = await this.#executeQuery(sql);
804
+ const targets = options?.maxIndexBytes === undefined
805
+ ? rows
806
+ : rows.filter((i) => Number(i.bytes) <= options.maxIndexBytes);
807
+ if (!targets.length)
808
+ return [];
809
+ const { rows: leftovers } = await this.#executeQuery(plans.getReindexLeftovers(schema, undefined, this.#config.noIndexProgressView, true));
810
+ return plans.buildReindexCommands(schema, targets, leftovers);
811
+ }
226
812
  }
227
813
  export default Boss;