pg-boss 12.25.0 → 12.26.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/plans.js CHANGED
@@ -1,13 +1,31 @@
1
+ import { indexKeysRaw, indexPredicateRaw, displayIndexDefinition, extractFunctionBody, normalizeFunctionBody } from "./drifter.js";
2
+ import schemaManifest from './schema.json' with { type: 'json' };
1
3
  export const PG_ERROR = {
2
4
  divisionByZero: '22012'
3
5
  };
4
6
  export const DEFAULT_SCHEMA = 'pgboss';
5
7
  export const MIGRATE_RACE_MESSAGE = 'division by zero';
6
8
  export const CREATE_RACE_MESSAGE = 'already exists';
7
- const SINGLE_QUOTE_REGEX = /'/g;
9
+ export const SINGLE_QUOTE_REGEX = /'/g;
8
10
  const FIFTEEN_MINUTES = 60 * 15;
9
11
  const FORTEEN_DAYS = 60 * 60 * 24 * 14;
10
12
  const SEVEN_DAYS = 60 * 60 * 24 * 7;
13
+ // A bam row stuck at 'in_progress' past this means the process that claimed it died or was
14
+ // stopped mid-command (bam.ts #processCommands returns without marking the row on #stopped or a
15
+ // crash), and getNextBamCommand needs to reclaim it or every future async migration wedges forever.
16
+ // This is the *fallback* backstop only — deliberately long (24h) because false-reclaim is the worse
17
+ // failure: reclaiming a still-running CREATE INDEX CONCURRENTLY runs two builds on the same index at
18
+ // once, and an interrupted CONCURRENTLY leaves an INVALID index needing manual cleanup. The intended
19
+ // *primary* trigger is a liveness check against pg_stat_progress_create_index (reclaim as soon as no
20
+ // backend is actually building), which recovers in minutes instead of a day; the 24h timeout only
21
+ // covers cases liveness can't classify (non-index commands, or a build that never registered).
22
+ const BAM_STALE_SECONDS = 60 * 60 * 24;
23
+ // Liveness grace window: on the native-Postgres path we don't trust a "no build running" reading
24
+ // until a claimed command has had time to actually start and register in pg_stat_progress_create_index
25
+ // (pool latency between claiming the row and issuing the build). Below this age, an in_progress row is
26
+ // only reclaimed via the 24h fallback, never via liveness — so a genuinely-running build is never
27
+ // yanked out from under itself.
28
+ const BAM_LIVENESS_GRACE_SECONDS = 60 * 5;
11
29
  export const JOB_STATES = Object.freeze({
12
30
  created: 'created',
13
31
  retry: 'retry',
@@ -34,7 +52,7 @@ const QUEUE_DEFAULTS = {
34
52
  retry_backoff: false,
35
53
  partition: false
36
54
  };
37
- const COMMON_JOB_TABLE = 'job_common';
55
+ export const COMMON_JOB_TABLE = 'job_common';
38
56
  export function create(schema, version, options) {
39
57
  const noPartitioning = options?.noTablePartitioning ?? false;
40
58
  const noDeferrable = options?.noDeferrableConstraints ?? false;
@@ -206,15 +224,20 @@ export function createTableJobDependency(schema) {
206
224
  export function createIndexJobDependencyParent(schema) {
207
225
  return `CREATE INDEX IF NOT EXISTS job_dep_parent_idx ON ${schema}.job_dependency (parent_name, parent_id)`;
208
226
  }
209
- function jobTableFormatFunction(schema) {
227
+ // Anchored so a schema name that itself contains these substrings (e.g. `job_intake`) isn't
228
+ // mangled: `\.job\y` matches only the base table reference (`schema.job`, not `schema.job_i5` whose
229
+ // `job` is followed by `_`, nor `.job_dependency`), and `\yjob_i(\d+)` matches only the bare
230
+ // index-name tokens (job_i1..9), never the `job_i` inside a schema name. Mirrors formatJobTable()
231
+ // in migrationStore.ts; the migration that fixed this (v37) carries its own frozen copy.
232
+ export function jobTableFormatFunction(schema) {
210
233
  return `
211
234
  CREATE FUNCTION ${schema}.job_table_format(command text, table_name text)
212
235
  RETURNS text AS
213
236
  $$
214
237
  SELECT format(
215
- replace(
216
- replace(command, '.job', '.%1$I'),
217
- 'job_i', '%1$s_i'
238
+ regexp_replace(
239
+ regexp_replace(command, '\\.job\\y', '.%1$I', 'g'),
240
+ '\\yjob_i(\\d+)', '%1$s_i\\1', 'g'
218
241
  ),
219
242
  table_name
220
243
  );
@@ -689,14 +712,14 @@ export function updateQueue(schema, { deadLetter } = {}) {
689
712
  retry_limit = COALESCE((o.data->>'retryLimit')::int, retry_limit),
690
713
  retry_delay = COALESCE((o.data->>'retryDelay')::int, retry_delay),
691
714
  retry_backoff = COALESCE((o.data->>'retryBackoff')::bool, retry_backoff),
692
- retry_delay_max = CASE WHEN o.data ? 'retryDelayMax'
715
+ retry_delay_max = CASE WHEN jsonb_exists(o.data, 'retryDelayMax')
693
716
  THEN (o.data->>'retryDelayMax')::int
694
717
  ELSE retry_delay_max END,
695
718
  expire_seconds = COALESCE((o.data->>'expireInSeconds')::int, expire_seconds),
696
719
  retention_seconds = COALESCE((o.data->>'retentionSeconds')::int, retention_seconds),
697
720
  deletion_seconds = COALESCE((o.data->>'deleteAfterSeconds')::int, deletion_seconds),
698
721
  warning_queued = COALESCE((o.data->>'warningQueueSize')::int, warning_queued),
699
- heartbeat_seconds = CASE WHEN o.data ? 'heartbeatSeconds'
722
+ heartbeat_seconds = CASE WHEN jsonb_exists(o.data, 'heartbeatSeconds')
700
723
  THEN (o.data->>'heartbeatSeconds')::int
701
724
  ELSE heartbeat_seconds END,
702
725
  notify = COALESCE((o.data->>'notify')::bool, notify),
@@ -853,22 +876,6 @@ export function deleteOldWarnings(schema, days) {
853
876
  `;
854
877
  }
855
878
  export function createTableQueueStats(schema, noPartitioning = false) {
856
- if (noPartitioning) {
857
- return `
858
- CREATE TABLE ${schema}.queue_stats (
859
- id uuid NOT NULL DEFAULT gen_random_uuid(),
860
- name text NOT NULL,
861
- deferred_count int NOT NULL DEFAULT 0,
862
- queued_count int NOT NULL DEFAULT 0,
863
- ready_count int NOT NULL DEFAULT 0,
864
- active_count int NOT NULL DEFAULT 0,
865
- failed_count int NOT NULL DEFAULT 0,
866
- total_count int NOT NULL DEFAULT 0,
867
- captured_on timestamptz NOT NULL DEFAULT now(),
868
- PRIMARY KEY (id)
869
- )
870
- `;
871
- }
872
879
  return `
873
880
  CREATE TABLE ${schema}.queue_stats (
874
881
  id uuid NOT NULL DEFAULT gen_random_uuid(),
@@ -880,22 +887,15 @@ export function createTableQueueStats(schema, noPartitioning = false) {
880
887
  failed_count int NOT NULL DEFAULT 0,
881
888
  total_count int NOT NULL DEFAULT 0,
882
889
  captured_on timestamptz NOT NULL DEFAULT now(),
883
- PRIMARY KEY (id, captured_on)
884
- ) PARTITION BY RANGE (captured_on)
890
+ ${noPartitioning ? 'PRIMARY KEY (id)' : 'PRIMARY KEY (id, captured_on)'}
891
+ ) ${noPartitioning ? '' : 'PARTITION BY RANGE (captured_on)'}
885
892
  `;
886
893
  }
887
- // queue_stats_i1 serves both the raw history query and the bucketed aggregates: the filter
888
- // (name = ?, captured_on range) rides the composite key, and the six count columns are carried as
889
- // covering payload so those reads run index-only (no per-row heap fetch — the dominant cost when an
890
- // aggregate scans many rows). INCLUDE is gated on the noCoveringIndexes profile flag, which
891
- // CockroachDB sets (it uses STORING, not INCLUDE) but YugabyteDB does not (it supports INCLUDE):
892
- // the gated backends keep the plain composite index — correct, just not covering.
893
894
  export function createIndexQueueStats(schema, noCoveringIndex = false) {
894
- const cols = '(name, captured_on DESC)';
895
- const include = 'INCLUDE (deferred_count, queued_count, ready_count, active_count, failed_count, total_count)';
896
- return noCoveringIndex
897
- ? `CREATE INDEX queue_stats_i1 ON ${schema}.queue_stats ${cols}`
898
- : `CREATE INDEX queue_stats_i1 ON ${schema}.queue_stats ${cols} ${include}`;
895
+ const include = noCoveringIndex
896
+ ? ''
897
+ : 'INCLUDE (deferred_count, queued_count, ready_count, active_count, failed_count, total_count)';
898
+ return `CREATE INDEX queue_stats_i1 ON ${schema}.queue_stats (name, captured_on DESC) ${include}`;
899
899
  }
900
900
  // Idempotently create the daily partitions for today and tomorrow (UTC). Both the day suffix and
901
901
  // the range bounds are derived in SQL from the UTC calendar date, and the bounds are emitted as
@@ -1116,7 +1116,12 @@ function buildFetchParams(options) {
1116
1116
  if (hasIgnoreSingletons) {
1117
1117
  paramIndex++;
1118
1118
  ignoreSingletonsParam = `$${paramIndex}::text[]`;
1119
- values.push(ignoreSingletons);
1119
+ // job_i2/job_i3 key singleton/stately jobs on the empty key (COALESCE(singleton_key, ''))
1120
+ // as one slot, so a keyless active job must block keyless pending jobs the same way a keyed
1121
+ // one blocks its key. Map null -> '' here so the WHERE clause's COALESCE comparison (below)
1122
+ // never has to compare against a literal NULL array element, which would make `<> ALL(...)`
1123
+ // evaluate to NULL (excluding every row) instead of the intended per-key filter.
1124
+ values.push(ignoreSingletons.map(key => key ?? ''));
1120
1125
  }
1121
1126
  if (hasIgnoreGroups) {
1122
1127
  paramIndex++;
@@ -1203,7 +1208,7 @@ export function fetchNextJob(options, noSkipLocked = false) {
1203
1208
  `j.state < '${JOB_STATES.active}'`,
1204
1209
  'NOT j.blocked',
1205
1210
  !ignoreStartAfter ? 'j.start_after < now()' : '',
1206
- hasIgnoreSingletons ? `j.singleton_key <> ALL(${params.ignoreSingletonsParam})` : '',
1211
+ hasIgnoreSingletons ? `COALESCE(j.singleton_key, '') <> ALL(${params.ignoreSingletonsParam})` : '',
1207
1212
  hasIgnoreGroups ? `(j.group_id IS NULL OR j.group_id <> ALL(${params.ignoreGroupsParam}))` : '',
1208
1213
  hasMinPriority ? `j.priority >= ${params.minPriorityParam}` : '',
1209
1214
  hasMaxPriority ? `j.priority <= ${params.maxPriorityParam}` : '',
@@ -1643,7 +1648,7 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
1643
1648
  WHEN NOT retry_backoff THEN now() + retry_delay * interval '1'
1644
1649
  ELSE now() + LEAST(
1645
1650
  retry_delay_max,
1646
- retry_delay * (
1651
+ GREATEST(retry_delay, 1) * (
1647
1652
  2 ^ LEAST(16, retry_count + 1) / 2 +
1648
1653
  2 ^ LEAST(16, retry_count + 1) / 2 * random()
1649
1654
  )
@@ -1744,7 +1749,7 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
1744
1749
  ),
1745
1750
  dlq_jobs as (
1746
1751
  INSERT INTO ${schema}.job (name, data, output, retry_limit, retry_backoff, retry_delay, keep_until, deletion_seconds,
1747
- source_name, source_id, source_created_on, source_retry_count)
1752
+ expire_seconds, source_name, source_id, source_created_on, source_retry_count, singleton_key, heartbeat_seconds)
1748
1753
  SELECT
1749
1754
  r.dead_letter,
1750
1755
  r.data,
@@ -1754,10 +1759,13 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
1754
1759
  q.retry_delay,
1755
1760
  now() + q.retention_seconds * interval '1s',
1756
1761
  q.deletion_seconds,
1762
+ q.expire_seconds,
1757
1763
  r.name,
1758
1764
  r.id,
1759
1765
  r.created_on,
1760
- r.retry_count
1766
+ r.retry_count,
1767
+ r.singleton_key,
1768
+ r.heartbeat_seconds
1761
1769
  FROM results r
1762
1770
  JOIN ${schema}.queue q ON q.name = r.dead_letter
1763
1771
  WHERE state = '${JOB_STATES.failed}'
@@ -1952,9 +1960,9 @@ export function insertRetryJob(schema, table) {
1952
1960
  export function insertDeadLetterJob(schema) {
1953
1961
  return `
1954
1962
  INSERT INTO ${schema}.job (name, data, output, retry_limit, retry_backoff, retry_delay, keep_until, deletion_seconds,
1955
- source_name, source_id, source_created_on, source_retry_count)
1963
+ expire_seconds, source_name, source_id, source_created_on, source_retry_count, singleton_key, heartbeat_seconds)
1956
1964
  SELECT $1, $2, $3, q.retry_limit, q.retry_backoff, q.retry_delay, now() + q.retention_seconds * interval '1s', q.deletion_seconds,
1957
- $4, $5, $6, $7
1965
+ q.expire_seconds, $4, $5, $6, $7, $8, $9
1958
1966
  FROM ${schema}.queue q WHERE q.name = $1
1959
1967
  `;
1960
1968
  }
@@ -1985,11 +1993,17 @@ export function redriveJobs(schema, table) {
1985
1993
  ins AS (
1986
1994
  INSERT INTO ${schema}.job
1987
1995
  (name, data, priority, retry_limit, retry_backoff, retry_delay, retry_delay_max,
1988
- expire_seconds, keep_until, deletion_seconds, policy)
1996
+ expire_seconds, keep_until, deletion_seconds, policy, singleton_key, heartbeat_seconds)
1989
1997
  SELECT COALESCE($2, m.source_name), m.data, m.priority, q.retry_limit, q.retry_backoff,
1990
1998
  q.retry_delay, q.retry_delay_max, q.expire_seconds,
1991
- now() + q.retention_seconds * interval '1s', q.deletion_seconds, q.policy
1999
+ now() + q.retention_seconds * interval '1s', q.deletion_seconds, q.policy,
2000
+ m.singleton_key, m.heartbeat_seconds
1992
2001
  FROM moved m JOIN ${schema}.queue q ON q.name = COALESCE($2, m.source_name)
2002
+ -- A destination queue's short/stately policy can still collide on (name, singleton_key)
2003
+ -- if two redriven jobs share a key (job_i1/job_i3); dropping just that row here, matching
2004
+ -- retried_jobs' ON CONFLICT DO NOTHING elsewhere, is preferable to aborting the whole batch.
2005
+ -- The dropped job has already been deleted from the DLQ by the moved CTE and is not restored.
2006
+ ON CONFLICT DO NOTHING
1993
2007
  RETURNING 1
1994
2008
  )
1995
2009
  SELECT count(*)::int AS moved FROM ins
@@ -2013,7 +2027,8 @@ export function retryJobs(schema, table) {
2013
2027
  WITH results as (
2014
2028
  UPDATE ${schema}.job
2015
2029
  SET state = '${JOB_STATES.retry}',
2016
- retry_limit = retry_limit + 1
2030
+ retry_limit = retry_limit + 1,
2031
+ completed_on = NULL
2017
2032
  WHERE name = $1
2018
2033
  AND id = ANY($2::uuid[])
2019
2034
  AND state = '${JOB_STATES.failed}'
@@ -2024,7 +2039,7 @@ export function retryJobs(schema, table) {
2024
2039
  }
2025
2040
  // Partial in-place edit of not-yet-active jobs, preserving id/state/singleton identity.
2026
2041
  // The payload ($1) is a jsonb object of ONLY the fields the caller supplied; each column is
2027
- // left untouched unless its key is present (`o.data ? 'key'`), so an update that carries just
2042
+ // left untouched unless its key is present (`jsonb_exists(o.data, 'key')`), so an update that carries just
2028
2043
  // `data` never clobbers an existing start_after/priority/etc. Targeting is by id or
2029
2044
  // singleton_key; when by key, `match` picks which of several pre-active matches to edit
2030
2045
  // (newest/oldest = one row via ORDER BY + LIMIT; all = every match). When `notify` is set the
@@ -2041,7 +2056,7 @@ export function updateJob(schema, table, name, by, match, notify = false) {
2041
2056
  // Resolve the incoming startAfter the same way insertJobs does (absolute 'Z' timestamp vs.
2042
2057
  // relative interval), falling back to the row's current start_after when not supplied.
2043
2058
  const resolvedStartAfter = `
2044
- CASE WHEN o.data ? 'startAfter'
2059
+ CASE WHEN jsonb_exists(o.data, 'startAfter')
2045
2060
  THEN CASE WHEN right(o.data->>'startAfter', 1) = 'Z'
2046
2061
  THEN (o.data->>'startAfter')::timestamptz
2047
2062
  ELSE now() + CAST(o.data->>'startAfter' AS interval) END
@@ -2066,24 +2081,35 @@ export function updateJob(schema, table, name, by, match, notify = false) {
2066
2081
  ),
2067
2082
  upd AS (
2068
2083
  UPDATE ${schema}.${table} job
2069
- SET data = CASE WHEN o.data ? 'data' THEN o.data->'data' ELSE job.data END,
2084
+ SET data = CASE WHEN jsonb_exists(o.data, 'data') THEN o.data->'data' ELSE job.data END,
2070
2085
  priority = COALESCE((o.data->>'priority')::int, job.priority),
2071
2086
  start_after = ${resolvedStartAfter},
2072
- keep_until = CASE WHEN o.data ? 'retentionSeconds'
2073
- THEN (${resolvedStartAfter}) + ((o.data->>'retentionSeconds')::int * interval '1s')
2087
+ keep_until = CASE
2088
+ WHEN jsonb_exists(o.data, 'retentionSeconds')
2089
+ THEN (${resolvedStartAfter}) + ((o.data->>'retentionSeconds')::int * interval '1s')
2090
+ -- When only start_after moves, slide keep_until by the same original retention window
2091
+ -- (keep_until - start_after) so pulling a job forward/back never leaves keep_until in
2092
+ -- the past, which the deletion sweep would treat as expired and remove the pending job.
2093
+ WHEN jsonb_exists(o.data, 'startAfter')
2094
+ THEN (${resolvedStartAfter}) + (job.keep_until - job.start_after)
2074
2095
  ELSE job.keep_until END,
2075
2096
  expire_seconds = COALESCE((o.data->>'expireInSeconds')::int, job.expire_seconds),
2076
2097
  deletion_seconds = COALESCE((o.data->>'deleteAfterSeconds')::int, job.deletion_seconds),
2077
2098
  retry_limit = COALESCE((o.data->>'retryLimit')::int, job.retry_limit),
2078
2099
  retry_delay = COALESCE((o.data->>'retryDelay')::int, job.retry_delay),
2079
2100
  retry_backoff = COALESCE((o.data->>'retryBackoff')::bool, job.retry_backoff),
2080
- retry_delay_max = CASE WHEN o.data ? 'retryDelayMax' THEN (o.data->>'retryDelayMax')::int ELSE job.retry_delay_max END,
2081
- dead_letter = CASE WHEN o.data ? 'deadLetter' THEN o.data->>'deadLetter' ELSE job.dead_letter END,
2082
- heartbeat_seconds = CASE WHEN o.data ? 'heartbeatSeconds' THEN (o.data->>'heartbeatSeconds')::int ELSE job.heartbeat_seconds END,
2083
- group_id = CASE WHEN o.data ? 'groupId' THEN o.data->>'groupId' ELSE job.group_id END,
2084
- group_tier = CASE WHEN o.data ? 'groupTier' THEN o.data->>'groupTier' ELSE job.group_tier END
2101
+ retry_delay_max = CASE WHEN jsonb_exists(o.data, 'retryDelayMax') THEN (o.data->>'retryDelayMax')::int ELSE job.retry_delay_max END,
2102
+ dead_letter = CASE WHEN jsonb_exists(o.data, 'deadLetter') THEN o.data->>'deadLetter' ELSE job.dead_letter END,
2103
+ heartbeat_seconds = CASE WHEN jsonb_exists(o.data, 'heartbeatSeconds') THEN (o.data->>'heartbeatSeconds')::int ELSE job.heartbeat_seconds END,
2104
+ group_id = CASE WHEN jsonb_exists(o.data, 'groupId') THEN o.data->>'groupId' ELSE job.group_id END,
2105
+ group_tier = CASE WHEN jsonb_exists(o.data, 'groupTier') THEN o.data->>'groupTier' ELSE job.group_tier END
2085
2106
  FROM o
2107
+ -- Re-check state < active on the locked row, not just in the unlocked target CTE. Under
2108
+ -- READ COMMITTED a concurrent fetchNextJob can activate a candidate between target selection
2109
+ -- and this UPDATE; EvalPlanQual re-evaluates this predicate on the freshly-locked row, so the
2110
+ -- guard here prevents mutating a job a worker has already started running.
2086
2111
  WHERE job.id IN (SELECT id FROM target)
2112
+ AND job.state < '${JOB_STATES.active}'
2087
2113
  RETURNING job.id, job.start_after
2088
2114
  )${tail}
2089
2115
  `;
@@ -2103,7 +2129,7 @@ export function getQueueStats(schema, table, queues) {
2103
2129
  FROM (
2104
2130
  SELECT
2105
2131
  name,
2106
- (count(*) FILTER (WHERE start_after > now()))::int as "deferredCount",
2132
+ (count(*) FILTER (WHERE start_after > now() AND state < '${JOB_STATES.active}'))::int as "deferredCount",
2107
2133
  (count(*) FILTER (WHERE state < '${JOB_STATES.active}'))::int as "queuedCount",
2108
2134
  (count(*) FILTER (WHERE state = '${JOB_STATES.active}'))::int as "activeCount",
2109
2135
  (count(*) FILTER (WHERE state = '${JOB_STATES.failed}'))::int as "failedCount",
@@ -2323,22 +2349,141 @@ export function getBlockedKeys(schema, table) {
2323
2349
  AND policy = '${QUEUE_POLICIES.key_strict_fifo}'
2324
2350
  `;
2325
2351
  }
2326
- export function getNextBamCommand(schema) {
2352
+ export function getNextBamCommand(schema, { useLiveness = false } = {}) {
2353
+ // Head-of-line note (shared by both variants): process all 'pending' commands (oldest first)
2354
+ // before retrying any 'failed' or stale 'in_progress' one, so a permanently-failing (or
2355
+ // crashed-mid-flight) command can't sit at the head of the queue and starve everything behind it.
2356
+ // Within a status, created_on preserves enqueue order.
2357
+ if (!useLiveness) {
2358
+ // Timeout-only path for engines without pg_stat_progress_create_index (CockroachDB/YugabyteDB):
2359
+ // a stuck in_progress row is reclaimed purely on the 24h fallback. No CONCURRENTLY healing, so no
2360
+ // reclaimed flag is emitted (bam.ts skips healing when noIndexProgressView is set anyway).
2361
+ return `
2362
+ UPDATE ${schema}.bam
2363
+ SET status = 'in_progress', started_on = now()
2364
+ WHERE id = (
2365
+ SELECT id FROM ${schema}.bam
2366
+ WHERE (
2367
+ status IN ('pending', 'failed')
2368
+ OR (status = 'in_progress' AND started_on < now() - interval '${BAM_STALE_SECONDS} seconds')
2369
+ )
2370
+ AND NOT EXISTS (
2371
+ SELECT 1 FROM ${schema}.bam
2372
+ WHERE status = 'in_progress' AND started_on >= now() - interval '${BAM_STALE_SECONDS} seconds'
2373
+ )
2374
+ ORDER BY (status != 'pending'), created_on
2375
+ LIMIT 1
2376
+ )
2377
+ RETURNING id, name, version, status, queue, table_name as "table", command, error,
2378
+ created_on as "createdOn", started_on as "startedOn", completed_on as "completedOn"
2379
+ `;
2380
+ }
2381
+ // Native-Postgres liveness path. An in_progress row counts as "stale" (reclaimable) when it is past
2382
+ // the grace window AND no backend is actually building its index right now. The same predicate,
2383
+ // negated, defines a genuinely-live command that must still block the queue — so a running build is
2384
+ // NEVER reclaimed (no matter how long it runs), and a dead one recovers within the grace window.
2385
+ // There is deliberately no 24h absolute cap here: liveBuild=true always means a build is in flight, so
2386
+ // capping on elapsed time would reclaim a genuinely-running build and start a second
2387
+ // CREATE INDEX CONCURRENTLY on the same index — the exact double-build this path exists to prevent.
2388
+ // (The timeout-only path's BAM_STALE_SECONDS fallback covers engines with no way to detect liveness.)
2389
+ //
2390
+ // liveBuild(tableCol): is a CREATE INDEX CONCURRENTLY actively building this table's index right now?
2391
+ // Detected via pg_locks, NOT pg_stat_progress_create_index — and that choice is load-bearing for
2392
+ // multi-instance safety. pg_stat_progress_* is filtered to the querying role's OWN backends (only a
2393
+ // superuser or a member of pg_read_all_stats sees another role's builds), so a progress-view check
2394
+ // silently reads a peer's live build as "dead" whenever pg-boss instances connect under different DB
2395
+ // roles — and the heal step (bamHealProbe/bamHealDrop in bam.ts) would then DROP INDEX CONCURRENTLY a
2396
+ // live index mid-build, racing the builder into a double CREATE. pg_locks, by contrast, is cluster-wide
2397
+ // and visible to every role (verified empirically). CREATE INDEX CONCURRENTLY holds a
2398
+ // ShareUpdateExclusiveLock on the target table for the ENTIRE build and releases it the instant the
2399
+ // statement finishes or the backend dies, so a granted SUExclusive lock on the row's table is a
2400
+ // crash-safe, role-agnostic "build in flight" signal — instances may run under different roles with no
2401
+ // loss of safety. Ordinary queue DML never takes SUExclusive (it uses AccessShare/RowShare/
2402
+ // RowExclusive), so it can't false-trigger; a concurrent autovacuum/ANALYZE on the same table DOES take
2403
+ // SUExclusive and reads as "live", but that false positive is in the SAFE direction — it only briefly
2404
+ // DEFERS a reclaim, never drops a live index. Scoped to the current database (l.database) because
2405
+ // pg_locks is cluster-wide while relation OIDs are only unique per database. Correlating on the table
2406
+ // is enough because the queue runs only one in_progress command at a time.
2407
+ const liveBuild = (tableCol) => `EXISTS (
2408
+ SELECT 1 FROM pg_locks l
2409
+ WHERE l.locktype = 'relation'
2410
+ AND l.granted
2411
+ AND l.mode = 'ShareUpdateExclusiveLock'
2412
+ AND l.database = (SELECT oid FROM pg_database WHERE datname = current_database())
2413
+ AND l.relation = to_regclass(quote_ident('${schema}') || '.' || quote_ident(${tableCol}))
2414
+ )`;
2415
+ const stale = (startedCol, tableCol) => `(
2416
+ ${startedCol} < now() - interval '${BAM_LIVENESS_GRACE_SECONDS} seconds'
2417
+ AND NOT ${liveBuild(tableCol)}
2418
+ )`;
2327
2419
  return `
2328
- UPDATE ${schema}.bam
2329
- SET status = 'in_progress', started_on = now()
2330
- WHERE id = (
2331
- SELECT id FROM ${schema}.bam
2332
- WHERE status IN ('pending', 'failed')
2333
- AND NOT EXISTS (SELECT 1 FROM ${schema}.bam WHERE status = 'in_progress')
2334
- -- Process all 'pending' commands (oldest first) before retrying any 'failed' one, so a
2335
- -- permanently-failing command can't sit at the head of the queue and starve everything behind
2336
- -- it (head-of-line blocking). Within a status, created_on preserves enqueue order.
2337
- ORDER BY (status = 'failed'), created_on
2420
+ WITH candidate AS (
2421
+ SELECT c.id, c.status AS prior_status
2422
+ FROM ${schema}.bam c
2423
+ WHERE (
2424
+ c.status IN ('pending', 'failed')
2425
+ OR (c.status = 'in_progress' AND ${stale('c.started_on', 'c.table_name')})
2426
+ )
2427
+ AND NOT EXISTS (
2428
+ SELECT 1 FROM ${schema}.bam g
2429
+ WHERE g.status = 'in_progress' AND NOT ${stale('g.started_on', 'g.table_name')}
2430
+ )
2431
+ ORDER BY (c.status != 'pending'), c.created_on
2338
2432
  LIMIT 1
2433
+ -- Defense-in-depth against a double-claim. The upstream trySetBamTime throttle serializes healers
2434
+ -- to one per interval, but a build running longer than bamIntervalSeconds lets a second instance
2435
+ -- pass the throttle while the first is still working. FOR UPDATE SKIP LOCKED makes the claim itself
2436
+ -- mutually exclusive: whichever instance locks the head row wins; the other skips it (and, with
2437
+ -- LIMIT 1, claims nothing) rather than re-running the same UPDATE and re-driving the command with a
2438
+ -- stale prior_status. OF c scopes the lock to the candidate row only, so the NOT EXISTS probe over
2439
+ -- bam g is never itself locked. Postgres-only path — SKIP LOCKED is deliberately absent from the
2440
+ -- timeout-only variant, which also serves CockroachDB/YugabyteDB where it performs poorly and can
2441
+ -- skip unexpectedly.
2442
+ FOR UPDATE OF c SKIP LOCKED
2339
2443
  )
2340
- RETURNING id, name, version, status, queue, table_name as "table", command, error,
2341
- created_on as "createdOn", started_on as "startedOn", completed_on as "completedOn"
2444
+ UPDATE ${schema}.bam b
2445
+ SET status = 'in_progress', started_on = now()
2446
+ FROM candidate
2447
+ WHERE b.id = candidate.id
2448
+ RETURNING b.id, b.name, b.version, b.status, b.queue, b.table_name as "table", b.command, b.error,
2449
+ b.created_on as "createdOn", b.started_on as "startedOn", b.completed_on as "completedOn",
2450
+ -- reattempt: was this command already tried once (stale-reclaimed in_progress OR a prior
2451
+ -- 'failed')? Either way an interrupted/failed CREATE INDEX CONCURRENTLY may have left an
2452
+ -- INVALID index, so the runner heals (drop-then-rebuild) before re-running. Fresh
2453
+ -- 'pending' rows have nothing to heal.
2454
+ (candidate.prior_status <> 'pending') as reattempt
2455
+ `;
2456
+ }
2457
+ // Derives the drop-then-rebuild heal step for a re-attempted BAM index build: an interrupted
2458
+ // CREATE INDEX CONCURRENTLY leaves an INVALID index in the catalog, and the command's own
2459
+ // IF NOT EXISTS would then skip forever (marking the row done while the index stays invalid).
2460
+ // Dropping it first lets the re-run rebuild cleanly. Returns null for non-index commands (which
2461
+ // have nothing to heal) so the caller just re-runs them. DROP ... CONCURRENTLY runs outside a
2462
+ // transaction, matching the CREATE, and IF EXISTS makes it a no-op when there's nothing to drop.
2463
+ export function bamHealDrop(schema, command) {
2464
+ const match = command.match(/CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+(?:IF\s+NOT\s+EXISTS\s+)?("?[\w$]+"?)/i);
2465
+ if (!match)
2466
+ return null;
2467
+ return `DROP INDEX CONCURRENTLY IF EXISTS ${schema}.${match[1]}`;
2468
+ }
2469
+ // Probe run before bamHealDrop: returns `invalid = true` only when the index the re-attempted command
2470
+ // would build already exists AND is INVALID (an interrupted CREATE INDEX CONCURRENTLY left a stub).
2471
+ // A build that actually finished but whose BAM row was never marked completed — e.g. a graceful stop
2472
+ // landed between the CREATE succeeding and markCompleted — leaves a VALID index; dropping that would
2473
+ // tear down a live production index for the whole rebuild window, so the caller must NOT heal it (the
2474
+ // re-run's own IF NOT EXISTS then no-ops and just marks the row done). Returns null for non-index
2475
+ // commands and for an absent index (no row), where there is nothing to drop. Mirrors bamHealDrop's
2476
+ // CONCURRENTLY-only recognition so it is non-null exactly when bamHealDrop is.
2477
+ export function bamHealProbe(schema, command) {
2478
+ const match = command.match(/CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([\w$]+)"?/i);
2479
+ if (!match)
2480
+ return null;
2481
+ return `
2482
+ SELECT NOT i.indisvalid AS invalid
2483
+ FROM pg_class c
2484
+ JOIN pg_index i ON i.indexrelid = c.oid
2485
+ JOIN pg_namespace n ON n.oid = c.relnamespace
2486
+ WHERE n.nspname = '${schema.replace(SINGLE_QUOTE_REGEX, "''")}' AND c.relname = '${match[1].replace(SINGLE_QUOTE_REGEX, "''")}'
2342
2487
  `;
2343
2488
  }
2344
2489
  export function setBamCompleted(schema, id) {
@@ -2371,3 +2516,162 @@ export function getBamEntries(schema) {
2371
2516
  ORDER BY version, created_on
2372
2517
  `;
2373
2518
  }
2519
+ // --- drift detection: live-catalog probes ---
2520
+ //
2521
+ // pg-boss-specific probes that read the live database for drift detection: partitioning topology and
2522
+ // in-flight BAM builds. Their results feed the generic engine in drifter.ts, which knows nothing about
2523
+ // pg-boss.
2524
+ // Probe for the default partition; its presence means the partitioned architecture is in use, so
2525
+ // drift detection reads it from the live database rather than trusting a boss config flag.
2526
+ export function jobCommonExists(schema) {
2527
+ return `SELECT to_regclass('${schema}.${COMMON_JOB_TABLE}') as name`;
2528
+ }
2529
+ // Per-queue partition tables and their policy, so the expected index set can be computed per table.
2530
+ export function getManagedQueuePartitions(schema) {
2531
+ return `SELECT table_name as "table", policy FROM ${schema}.queue WHERE partition = true`;
2532
+ }
2533
+ // The CREATE INDEX command text of every BAM row not yet completed — used to tell a genuinely-missing
2534
+ // index apart from one an async build is still working on (or retrying after a failure).
2535
+ export function getIncompleteBamCommands(schema) {
2536
+ return `SELECT command FROM ${schema}.bam WHERE status <> 'completed'`;
2537
+ }
2538
+ // Extracts the index name a BAM command builds, so an incomplete build can be matched to a missing
2539
+ // index. Mirrors the CREATE INDEX shapes bamHealDrop recognises.
2540
+ export function bamCommandIndexName(command) {
2541
+ const match = command.match(/CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?"?([\w$]+)"?/i);
2542
+ return match ? match[1] : null;
2543
+ }
2544
+ // job_iN partial indexes that gate on a queue policy: a per-queue partition table (partition:true)
2545
+ // only receives the index for its own policy (see create_queue, createQueueFunction). The shared
2546
+ // job_common table and a non-partitioned job table carry all of them at once. Keep in sync with the
2547
+ // createIndexJobPolicy* builders and the ELSIF ladder in createQueueFunction.
2548
+ const POLICY_JOB_INDEXES = {
2549
+ 1: QUEUE_POLICIES.short,
2550
+ 2: QUEUE_POLICIES.singleton,
2551
+ 3: QUEUE_POLICIES.stately,
2552
+ 6: QUEUE_POLICIES.exclusive,
2553
+ 8: QUEUE_POLICIES.key_strict_fifo
2554
+ };
2555
+ // job_iN indexes with no policy gate — created on every job table regardless of policy
2556
+ // (throttle i4, fetch i5, group-concurrency i7, blocking i9).
2557
+ const BASE_JOB_INDEXES = [4, 5, 7, 9];
2558
+ // The fixed (non-job) managed tables; job/job_common/partitions are handled separately.
2559
+ const FIXED_MANAGED_TABLES = ['version', 'queue', 'schedule', 'subscription', 'bam', 'warning', 'queue_stats', 'job_dependency'];
2560
+ // Selects the manifest section for the live architecture, and substitutes the real schema name back in
2561
+ // for the placeholder the manifest stores.
2562
+ function manifestSection(partitioned) {
2563
+ return partitioned ? schemaManifest.partitioned : schemaManifest.nonPartitioned;
2564
+ }
2565
+ function applyManifestSchema(text, schema) {
2566
+ return text.split(schemaManifest.schemaToken).join(schema);
2567
+ }
2568
+ // The job_state enum values in declaration order, from the manifest (both sections carry the same enum).
2569
+ // Order is significant — the numeric base type makes created < retry < … < failed load-bearing.
2570
+ export const EXPECTED_JOB_STATES = schemaManifest.partitioned.enum;
2571
+ // The tables pg-boss expects to exist. The manifest lists the fixed tables plus job (and job_common in
2572
+ // partitioned mode); per-queue partition tables are dynamic, so they are appended from the live set.
2573
+ export function expectedManagedTables(schema, partitioned, partitions = []) {
2574
+ const tables = [...manifestSection(partitioned).tables];
2575
+ if (partitioned)
2576
+ for (const p of partitions)
2577
+ tables.push(p.table);
2578
+ return tables;
2579
+ }
2580
+ // The columns pg-boss expects on each managed table, taken from the manifest (introspected column type,
2581
+ // nullability, and default). Fixed tables carry the full defaults + types maps so column defaults, data
2582
+ // types, and nullability are diffed; the job table (shared by job_common and every per-queue partition
2583
+ // in partitioned mode) is name-only — its FKs are profile-dependent and keep_until's interval default is
2584
+ // not worth pinning per-partition. A table with no live columns is skipped by the diff, so listing one
2585
+ // that does not exist yet is harmless.
2586
+ export function expectedManagedColumns(schema, partitioned, partitions = []) {
2587
+ const fixed = new Set(FIXED_MANAGED_TABLES);
2588
+ const byTable = new Map();
2589
+ const jobColumns = [];
2590
+ for (const c of manifestSection(partitioned).columns) {
2591
+ if (c.table === 'job') {
2592
+ jobColumns.push(c.column);
2593
+ continue;
2594
+ }
2595
+ if (!fixed.has(c.table))
2596
+ continue; // job_common / anything else derives from the job template below
2597
+ let entry = byTable.get(c.table);
2598
+ if (!entry)
2599
+ byTable.set(c.table, entry = { table: c.table, columns: [], defaults: {}, types: {} });
2600
+ entry.columns.push(c.column);
2601
+ entry.types[c.column] = { type: applyManifestSchema(c.type, schema), notNull: c.notNull };
2602
+ if (c.default != null)
2603
+ entry.defaults[c.column] = applyManifestSchema(c.default, schema);
2604
+ }
2605
+ const out = [...byTable.values()];
2606
+ out.push({ table: 'job', columns: jobColumns });
2607
+ if (partitioned) {
2608
+ out.push({ table: COMMON_JOB_TABLE, columns: jobColumns });
2609
+ for (const p of partitions)
2610
+ out.push({ table: p.table, columns: jobColumns });
2611
+ }
2612
+ return out;
2613
+ }
2614
+ // The constraints pg-boss expects on each FIXED managed table, taken verbatim from the manifest's
2615
+ // pg_get_constraintdef capture (job/job_common/partitions are excluded: their FKs are profile-dependent
2616
+ // DEFERRABLE). Compared as a normalized set, so order is irrelevant. The fresh-install integration test
2617
+ // verifies the manifest matches live.
2618
+ export function expectedManagedConstraints(schema, partitioned) {
2619
+ const fixed = new Set(FIXED_MANAGED_TABLES);
2620
+ const byTable = new Map();
2621
+ for (const { table, def } of manifestSection(partitioned).constraints) {
2622
+ if (!fixed.has(table))
2623
+ continue;
2624
+ const list = byTable.get(table) ?? byTable.set(table, []).get(table);
2625
+ list.push(applyManifestSchema(def, schema));
2626
+ }
2627
+ return [...byTable].map(([table, constraints]) => ({ table, constraints }));
2628
+ }
2629
+ // The functions pg-boss expects to exist in `schema`, from the manifest's pg_get_functiondef capture
2630
+ // (the partitioned architecture has the three job_table_* helpers plus partition-aware create_queue/
2631
+ // delete_queue; non-partitioned mode has neither the helpers nor the partition branches). Each entry
2632
+ // carries the whitespace-normalised body used for the diff and the full statement for remediation.
2633
+ // Postgres stores a function body verbatim, so the manifest body compares equal to the live one.
2634
+ export function expectedManagedFunctions(schema, partitioned) {
2635
+ return manifestSection(partitioned).functions.map(fn => {
2636
+ const def = applyManifestSchema(fn.def, schema);
2637
+ return {
2638
+ name: fn.name,
2639
+ expectedBody: normalizeFunctionBody(extractFunctionBody(def)),
2640
+ definition: def.replace(/\s+/g, ' ').trim()
2641
+ };
2642
+ });
2643
+ }
2644
+ // Computes the set of indexes pg-boss expects to exist in `schema`, sourced from the manifest.
2645
+ // `partitioned` reflects the live database (job_common present ⇒ partitioned architecture), so this
2646
+ // needs no boss config. The manifest holds every fixed index (the static ones plus the job_iN set on
2647
+ // job/job_common); each per-queue partition is dynamic, so its indexes are templated here from the
2648
+ // job_common set — the base indexes always, plus the one for the queue's policy. keys/predicate/
2649
+ // definition are derived from the catalog-canonical pg_get_indexdef the manifest stores.
2650
+ export function expectedManagedIndexes(schema, partitioned, partitions = []) {
2651
+ const managed = (name, table, indexdef) => {
2652
+ const def = applyManifestSchema(indexdef, schema);
2653
+ return { name, table, keys: indexKeysRaw(def), predicate: indexPredicateRaw(def), definition: displayIndexDefinition(def) };
2654
+ };
2655
+ const jobTable = partitioned ? COMMON_JOB_TABLE : 'job';
2656
+ const out = [];
2657
+ const jobIndexes = [];
2658
+ // The manifest holds only the managed indexes (constraint-backing *_pkey indexes are excluded at
2659
+ // generation), so every row is an expectation.
2660
+ for (const idx of manifestSection(partitioned).indexes) {
2661
+ out.push(managed(idx.name, idx.table, idx.def));
2662
+ const n = idx.table === jobTable ? idx.name.match(/_i(\d+)$/) : null;
2663
+ if (n)
2664
+ jobIndexes.push({ n: Number(n[1]), def: idx.def });
2665
+ }
2666
+ // Per-queue partition tables are dynamic, so template each applicable job_common index onto them.
2667
+ if (partitioned) {
2668
+ for (const p of partitions) {
2669
+ for (const { n, def } of jobIndexes) {
2670
+ if (!BASE_JOB_INDEXES.includes(n) && POLICY_JOB_INDEXES[n] !== p.policy)
2671
+ continue;
2672
+ out.push(managed(`${p.table}_i${n}`, p.table, def.split(COMMON_JOB_TABLE).join(p.table)));
2673
+ }
2674
+ }
2675
+ }
2676
+ return out;
2677
+ }