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/adapters/knex.d.ts.map +1 -1
- package/dist/adapters/knex.js +6 -1
- package/dist/attorney.d.ts.map +1 -1
- package/dist/attorney.js +31 -9
- package/dist/bam.d.ts.map +1 -1
- package/dist/bam.js +23 -1
- package/dist/boss.d.ts.map +1 -1
- package/dist/boss.js +15 -20
- package/dist/cli.js +147 -3
- package/dist/contractor.d.ts +1 -0
- package/dist/contractor.d.ts.map +1 -1
- package/dist/contractor.js +97 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +9 -1
- package/dist/drifter.d.ts +84 -0
- package/dist/drifter.d.ts.map +1 -0
- package/dist/drifter.js +423 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +98 -55
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +68 -22
- package/dist/migrationStore.d.ts +2 -1
- package/dist/migrationStore.d.ts.map +1 -1
- package/dist/migrationStore.js +77 -4
- package/dist/plans.d.ts +24 -2
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +375 -71
- package/dist/schema.json +2069 -0
- package/dist/timekeeper.d.ts.map +1 -1
- package/dist/timekeeper.js +10 -4
- package/dist/types.d.ts +139 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -7
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
'
|
|
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
|
|
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
|
|
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
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2073
|
-
|
|
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
|
|
2081
|
-
dead_letter = CASE WHEN o.data
|
|
2082
|
-
heartbeat_seconds = CASE WHEN o.data
|
|
2083
|
-
group_id = CASE WHEN o.data
|
|
2084
|
-
group_tier = CASE WHEN o.data
|
|
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
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
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
|
-
|
|
2341
|
-
|
|
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
|
+
}
|