Skip to content

Commit e7a70e4

Browse files
committed
fixes and tests
1 parent 7d8e93e commit e7a70e4

12 files changed

Lines changed: 549 additions & 61 deletions

File tree

docs/api/constructor.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ The following configuration options should not normally need to be changed, but
149149
| --- | --- | --- | --- |
150150
| `minPages` | int | 128 | Ignore indexes smaller than this many 8 kB pages |
151151
| `maxEntriesPerPage` | number | 5 | Live entries per page below which an index counts as bloated. A freshly built job index holds 140-170 |
152+
| `minSizeRatio` | number | 4 | How many times larger than its live entries need an index must be. The needed size is estimated from `pg_stats`, so a wide `singletonKey` — which legitimately packs fewer than five entries per page — is not mistaken for bloat |
152153
| `maxIndexBytes` | int | 2147483648 | Never rebuild an index larger than this |
153154
154155
```js

docs/api/ops.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ const version = await boss.schemaVersion()
8181

8282
Runs one maintenance pass immediately instead of waiting for the next background cycle: monitoring (backlog warnings, expired and heartbeat-abandoned jobs, cached stats), deletion of jobs past their retention, warning and queue-stat pruning, and the index bloat check.
8383

84+
Passing `name` restricts the pass to that queue's own rows, but the index bloat check works on tables: for a queue with `partition: false` (the default) the indexes it would rebuild belong to the shared `job_common` table, which every other unpartitioned queue also uses.
85+
8486
This is the same pass the background supervisor runs on `superviseIntervalSeconds`. Call it directly when you have set `supervise: false` and drive maintenance yourself, or in tests where waiting for a timer is not an option.
8587

8688
```js
@@ -109,11 +111,11 @@ Steps within a pass are individually rate-limited by their own intervals (`maint
109111
### `getReindexCommands(options)`
110112

111113
**Arguments**
112-
- `options`: object, optional. Accepts `force`, `minPages`, `maxEntriesPerPage`, and `maxIndexBytes`.
114+
- `options`: object, optional. Accepts `force`, `minPages`, `maxEntriesPerPage`, `minSizeRatio`, and `maxIndexBytes`.
113115

114116
Returns the SQL statements that would rebuild the currently bloated job indexes, in the order they should be run, including a `DROP INDEX CONCURRENTLY` for any invalid stub left behind by an interrupted rebuild.
115117

116-
Use this where pg-boss cannot run the rebuild itself — the connected role does not own the indexes, or the `db` adapter wraps queries in a transaction (`REINDEX CONCURRENTLY` cannot run inside one). Unlike the background pass, no ownership filter and no size cap are applied unless `maxIndexBytes` is passed, since the commands are intended for an operator who may run them as a different role.
118+
Use this where pg-boss cannot run the rebuild itself — the connected role does not own the indexes, or the `db` adapter wraps queries in a transaction (`REINDEX CONCURRENTLY` cannot run inside one). Returns an empty array on CockroachDB and YugabyteDB, which have no btree bloat to reclaim and reject `REINDEX` in any form. Unlike the background pass, no ownership filter and no size cap are applied unless `maxIndexBytes` is passed, since the commands are intended for an operator who may run them as a different role.
117119

118120
```js
119121
const commands = await boss.getReindexCommands()

docs/api/utils.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ const sql = getRollbackPlans('pgboss', 36)
5050

5151
**Arguments**
5252
- `schema`: string, database schema name
53-
- `options`: object, optional. Accepts `minPages` (default 128) and `maxEntriesPerPage` (default 5).
53+
- `options`: object, optional. Accepts `minPages` (default 128), `maxEntriesPerPage` (default 5) and `minSizeRatio` (default 4).
5454

55-
Returns the catalog query pg-boss uses to find bloated job indexes, as SQL text. Unlike [`getReindexCommands()`](./ops.md#getreindexcommandsoptions) this needs no instance and no connection from this process — it is meant to be pasted into psql or handed to a monitoring tool.
55+
Returns the catalog query pg-boss uses to find bloated job indexes, as SQL text. PostgreSQL only — CockroachDB and YugabyteDB do not answer it. Unlike [`getReindexCommands()`](./ops.md#getreindexcommandsoptions) this needs no instance and no connection from this process — it is meant to be pasted into psql or handed to a monitoring tool.
5656

5757
```js
5858
const sql = getIndexBloatPlans('pgboss')

src/attorney.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,12 +738,14 @@ function validateReindexConfig (config: any) {
738738
'configuration assert: reindex must be a boolean or an options object')
739739

740740
if (typeof config.reindex === 'object' && config.reindex !== null) {
741-
const { minPages, maxEntriesPerPage, maxIndexBytes, force } = config.reindex
741+
const { minPages, maxEntriesPerPage, minSizeRatio, maxIndexBytes, force } = config.reindex
742742

743743
assert(minPages === undefined || (Number.isInteger(minPages) && minPages >= 0),
744744
'configuration assert: reindex.minPages must be an integer >= 0')
745745
assert(maxEntriesPerPage === undefined || (typeof maxEntriesPerPage === 'number' && maxEntriesPerPage > 0),
746746
'configuration assert: reindex.maxEntriesPerPage must be a number > 0')
747+
assert(minSizeRatio === undefined || (typeof minSizeRatio === 'number' && minSizeRatio >= 0),
748+
'configuration assert: reindex.minSizeRatio must be a number >= 0')
747749
assert(maxIndexBytes === undefined || (Number.isInteger(maxIndexBytes) && maxIndexBytes > 0),
748750
'configuration assert: reindex.maxIndexBytes must be an integer > 0')
749751
// force belongs to an explicit supervise() call, not to a background timer that would then

src/boss.ts

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ class Boss extends EventEmitter implements types.EventsMixin {
4444
// Warn once per bloated index rather than on every pass. An index leaves the set as soon as it
4545
// stops qualifying — rebuilt, dropped, or refilled — so a later episode warns again.
4646
#warnedBloat = new Set<string>()
47+
// Local rate limit for passes that only report bloat, which deliberately leave the shared interval
48+
// claim to whichever instance can act on it.
49+
#detectOnly = 0
4750

4851
events = events
4952

@@ -344,13 +347,24 @@ class Boss extends EventEmitter implements types.EventsMixin {
344347

345348
const resolved = this.#resolveReindexOptions(options)
346349
const force = !!resolved?.force
350+
const rebuilding = resolved !== null && !this.#reindexUnavailable
347351

348-
// An explicit force is a request to run now; everything else waits for the shared interval so
349-
// exactly one instance in the cluster does the work.
352+
// An explicit force is a request to run now; everything else waits for an interval.
353+
//
354+
// Which interval depends on whether this instance can do the work. The shared claim exists so
355+
// exactly one instance in the cluster rebuilds per window — an instance that is only ever going
356+
// to report bloat has no business taking it, or a peer configured to rebuild would find the
357+
// window gone and skip the rebuild for a whole day. Detection-only passes throttle themselves
358+
// locally instead, on the same interval.
350359
if (!force) {
351-
const claim = plans.trySetReindexTime(this.#config.schema, this.#config.reindexIntervalSeconds)
352-
const { rows } = await this.#executeQuery(claim)
353-
if (!rows.length) return
360+
if (rebuilding) {
361+
const claim = plans.trySetReindexTime(this.#config.schema, this.#config.reindexIntervalSeconds)
362+
const { rows } = await this.#executeQuery(claim)
363+
if (!rows.length) return
364+
} else {
365+
if (Date.now() < this.#detectOnly) return
366+
this.#detectOnly = Date.now() + this.#config.reindexIntervalSeconds * 1000
367+
}
354368
}
355369

356370
if (this.#stopping) return
@@ -359,8 +373,6 @@ class Boss extends EventEmitter implements types.EventsMixin {
359373
const detectSql = plans.getBloatedIndexes(this.#config.schema, scope, resolved ?? undefined)
360374
const { rows: bloated } = await this.#executeQuery(detectSql)
361375

362-
const rebuilding = resolved !== null && !this.#reindexUnavailable
363-
364376
let targets: types.IndexBloat[] = []
365377

366378
if (rebuilding) {
@@ -407,7 +419,7 @@ class Boss extends EventEmitter implements types.EventsMixin {
407419
// must not stop the rebuilds, since the retry's own IF EXISTS handles the common case anyway.
408420
async #dropReindexLeftovers (tables?: string[]) {
409421
try {
410-
const { rows } = await this.#executeQuery(plans.getReindexLeftovers(this.#config.schema, tables))
422+
const { rows } = await this.#executeQuery(plans.getReindexLeftovers(this.#config.schema, tables, this.#config.noIndexProgressView))
411423

412424
for (const leftover of rows) {
413425
if (this.#stopping) return
@@ -435,12 +447,17 @@ class Boss extends EventEmitter implements types.EventsMixin {
435447

436448
if (this.#warnedBloat.has(index.name)) continue
437449

450+
// Ownership first: an index the role cannot touch was never a candidate, so it has no entry in
451+
// `failed` no matter why the pass stopped. #reindexUnavailable comes next and covers the
452+
// indexes the 25001 giveup skipped without attempting — they are neither failed nor rebuilt,
453+
// and reporting a size cap they are nowhere near would point at the wrong knob.
438454
const reason = failed.get(index.name) ??
439-
(!rebuilding
440-
? (this.#reindexUnavailable ?? 'automatic reindexing is disabled')
441-
: !index.owned
442-
? 'the connected role does not own the index'
443-
: 'the index is larger than maxIndexBytes')
455+
(!index.owned
456+
? 'the connected role does not own the index'
457+
: this.#reindexUnavailable ??
458+
(!rebuilding
459+
? 'automatic reindexing is disabled'
460+
: 'the index is larger than maxIndexBytes'))
444461

445462
await emitAndPersistWarning(this.#warningContext,
446463
WARNING_TYPES.INDEX_BLOAT,
@@ -458,6 +475,11 @@ class Boss extends EventEmitter implements types.EventsMixin {
458475
* is passed — the commands are for an operator, who may run them as a different role.
459476
*/
460477
async getReindexCommands (options?: types.ReindexOptions): Promise<string[]> {
478+
// The catalog query reads pg_class.relpages and pg_relation_size(), which the heap-less engines
479+
// either reject outright or answer with zeroes — same gate as #reindex, and there is nothing to
480+
// rebuild on them anyway.
481+
if (this.#config.noReindex) return []
482+
461483
const schema = this.#config.schema
462484

463485
const sql = options?.force
@@ -472,21 +494,9 @@ class Boss extends EventEmitter implements types.EventsMixin {
472494

473495
if (!targets.length) return []
474496

475-
const { rows: leftovers } = await this.#executeQuery(plans.getReindexLeftovers(schema))
476-
const commands: string[] = []
477-
478-
for (const target of targets) {
479-
// Postgres names the transient index `<index>_ccnew`, then `_ccnew1`, `_ccnew2` on collision.
480-
for (const leftover of leftovers) {
481-
if (leftover.name.startsWith(`${target.name}_ccnew`)) {
482-
commands.push(plans.dropIndexConcurrently(schema, leftover.name))
483-
}
484-
}
485-
486-
commands.push(plans.reindexIndex(schema, target.name))
487-
}
497+
const { rows: leftovers } = await this.#executeQuery(plans.getReindexLeftovers(schema, undefined, this.#config.noIndexProgressView, true))
488498

489-
return commands
499+
return plans.buildReindexCommands(schema, targets, leftovers)
490500
}
491501
}
492502

src/cli.ts

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,22 @@ async function cmdReindex (args: ReturnType<typeof parseCliArgs>): Promise<void>
538538
}
539539

540540
const sql = args.force ? plans.getJobIndexes(schema) : plans.getBloatedIndexes(schema)
541-
const { rows } = await db.executeSql(sql)
541+
542+
let rows: any[]
543+
544+
try {
545+
({ rows } = await db.executeSql(sql))
546+
} catch (err: any) {
547+
// The check reads pg_class.relpages and pg_relation_size(). CockroachDB has neither (it
548+
// rejects `reltuples / relpages` as an unsupported binary operator) and YugabyteDB reports
549+
// zeroes for every relation. The CLI takes a connection string, not a backend profile, so
550+
// there is nothing to gate on ahead of time — say what happened instead of surfacing a raw
551+
// catalog error.
552+
console.error(`Could not read index statistics from schema "${schema}": ${err.message}`)
553+
console.error('The bloat check reads pg_class.relpages and pg_relation_size(), which CockroachDB and YugabyteDB do not provide.')
554+
process.exitCode = 1
555+
return
556+
}
542557

543558
if (!rows.length) {
544559
console.log(args.force
@@ -547,24 +562,22 @@ async function cmdReindex (args: ReturnType<typeof parseCliArgs>): Promise<void>
547562
return
548563
}
549564

550-
const { rows: leftovers } = await db.executeSql(plans.getReindexLeftovers(schema))
551-
552-
const commands: string[] = []
553-
554-
for (const index of rows) {
555-
for (const leftover of leftovers) {
556-
// Postgres names the transient index `<index>_ccnew`, then `_ccnew1`, `_ccnew2` on collision.
557-
if (leftover.name.startsWith(`${index.name}_ccnew`)) {
558-
commands.push(plans.dropIndexConcurrently(schema, leftover.name))
559-
}
560-
}
561-
562-
commands.push(plans.reindexIndex(schema, index.name))
563-
}
565+
// anyOwner for the printed list, which may be run as a different role; the execution path below
566+
// filters ownership itself.
567+
const { rows: leftovers } = await db.executeSql(plans.getReindexLeftovers(schema, undefined, false, args.dryRun))
564568

565569
if (args.dryRun) {
570+
// Every index, ownership included: the printed SQL is for an operator, who may well run it as
571+
// the role that does own them.
572+
const commands = plans.buildReindexCommands(schema, rows, leftovers)
573+
566574
console.log(`-- SQL to rebuild ${rows.length} index(es) in schema "${schema}":`)
567575
for (const command of commands) console.log(`${command};`)
576+
577+
for (const index of rows.filter(i => !i.owned)) {
578+
console.log(`-- note: "${index.name}" is not owned by ${config.user || 'the connected role'} and needs a role that can REINDEX it`)
579+
}
580+
568581
return
569582
}
570583

@@ -573,9 +586,25 @@ async function cmdReindex (args: ReturnType<typeof parseCliArgs>): Promise<void>
573586
console.log(` ${index.table}.${index.name}${mb} MB across ${index.pages} pages, ~${index.entries} live entries`)
574587
}
575588

576-
console.log(`\nRebuilding ${rows.length} index(es)...`)
589+
// Executing is different from printing: REINDEX needs ownership, so an index this role cannot
590+
// touch is reported up front rather than attempted for the sake of the server's error message.
591+
const skipped = rows.filter(index => !index.owned)
592+
const targets = rows.filter(index => index.owned)
593+
594+
for (const index of skipped) {
595+
console.error(` ✗ ${index.name}\n the connected role does not own this index and cannot reindex it`)
596+
}
597+
598+
if (!targets.length) {
599+
process.exitCode = 1
600+
return
601+
}
602+
603+
const commands = plans.buildReindexCommands(schema, targets, leftovers)
604+
605+
console.log(`\nRebuilding ${targets.length} index(es)...`)
577606

578-
let failures = 0
607+
let failures = skipped.length
579608

580609
for (const command of commands) {
581610
try {

src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export function getMigrationPlans (schema?: string, version?: number, options?:
3535

3636
/**
3737
* The catalog query pg-boss uses to find bloated job indexes, as SQL text. Runnable in psql with no
38-
* pg-boss instance and no connection from this process.
38+
* pg-boss instance and no connection from this process. PostgreSQL only — the heap-less engines
39+
* (CockroachDB, YugabyteDB) do not answer it.
3940
*/
4041
export function getIndexBloatPlans (schema?: string, options?: types.IndexBloatOptions) {
4142
return plans.getBloatedIndexes(schema || plans.DEFAULT_SCHEMA, undefined, options)
@@ -468,8 +469,9 @@ export class PgBoss extends EventEmitter<types.PgBossEventMap> {
468469
* left by an interrupted rebuild.
469470
*
470471
* For installations where pg-boss cannot run them itself — a role that doesn't own the indexes,
471-
* an adapter that wraps queries in a transaction, or a distributed backend. Pass
472-
* `{ force: true }` for every job index rather than only the bloated ones.
472+
* or an adapter that wraps queries in a transaction. Pass `{ force: true }` for every job index
473+
* rather than only the bloated ones. Empty on CockroachDB and YugabyteDB, which have no btree
474+
* bloat to reclaim and reject `REINDEX` in any form.
473475
*/
474476
getReindexCommands (options?: types.ReindexOptions): Promise<string[]> {
475477
return this.#boss.getReindexCommands(options)

0 commit comments

Comments
 (0)