pg-boss 12.33.2 → 12.33.4

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
@@ -15,7 +15,7 @@ const SEVEN_DAYS = 60 * 60 * 24 * 7;
15
15
  // A bam row stuck at 'in_progress' past this means the process that claimed it died or was
16
16
  // stopped mid-command (bam.ts #processCommands returns without marking the row on #stopped or a
17
17
  // crash), and getNextBamCommand needs to reclaim it or every future async migration wedges forever.
18
- // This is the *fallback* backstop only — deliberately long (24h) because false-reclaim is the worse
18
+ // This is the *fallback* backstop only, deliberately long (24h) because false-reclaim is the worse
19
19
  // failure: reclaiming a still-running CREATE INDEX CONCURRENTLY runs two builds on the same index at
20
20
  // once, and an interrupted CONCURRENTLY leaves an INVALID index needing manual cleanup. The intended
21
21
  // *primary* trigger is a liveness check against pg_stat_progress_create_index (reclaim as soon as no
@@ -25,7 +25,7 @@ const BAM_STALE_SECONDS = 60 * 60 * 24;
25
25
  // Liveness grace window: on the native-Postgres path we don't trust a "no build running" reading
26
26
  // until a claimed command has had time to actually start and register in pg_stat_progress_create_index
27
27
  // (pool latency between claiming the row and issuing the build). Below this age, an in_progress row is
28
- // only reclaimed via the 24h fallback, never via liveness — so a genuinely-running build is never
28
+ // only reclaimed via the 24h fallback, never via liveness, so a genuinely-running build is never
29
29
  // yanked out from under itself.
30
30
  const BAM_LIVENESS_GRACE_SECONDS = 60 * 5;
31
31
  export const JOB_STATES = Object.freeze({
@@ -323,7 +323,7 @@ function createTableSubscription(schema) {
323
323
  `;
324
324
  }
325
325
  // created_on defaults to clock_timestamp(), not ${schema}.job_now(), so multiple job_table_run_async() enqueues
326
- // within a single migration transaction keep their insertion order — BAM applies queued commands in
326
+ // within a single migration transaction keep their insertion order, BAM applies queued commands in
327
327
  // created_on order, and some migrations enqueue an ordered drop-then-rebuild pair (see v33).
328
328
  /* eslint-enable no-restricted-syntax */
329
329
  /* eslint-disable no-restricted-syntax -- bam.created_on orders several enqueues within one transaction; the schema clock would tie them */
@@ -825,14 +825,14 @@ function createIndexJobFetch(schema, noCoveringIndex = false) {
825
825
  // linearly. INCLUDE cannot help: FOR UPDATE forces an Index Scan, never an Index Only Scan.
826
826
  //
827
827
  // - `id` is deliberately NOT a key column. The obvious shape, (name, priority DESC, created_on,
828
- // id), also satisfies the ordering — but id is a random uuid, so it defeats btree
828
+ // id), also satisfies the ordering, but id is a random uuid, so it defeats btree
829
829
  // deduplication and dominates size: 24 MB against 19 MB for this shape at 500k rows, on the
830
830
  // hot insert path and on every vacuum's bulkdelete. It buys nothing, because fetchNextJob
831
831
  // dropped id from its ORDER BY for the same reason (a random uuid was never creation order);
832
832
  // with the ordering ending at created_on, this index satisfies it outright and the plan
833
833
  // carries no sort node at all.
834
834
  //
835
- // Also measured: neutral for key_strict_fifo (that fetch never reaches this index — the
835
+ // Also measured: neutral for key_strict_fifo (that fetch never reaches this index. The
836
836
  // strict_fifo_heads CTE on job_i10 feeds the outer CTE), and a ~7x win on CockroachDB, which
837
837
  // takes the noSkipLocked path and so does not depend on Incremental Sort at all.
838
838
  //
@@ -996,23 +996,29 @@ export function setMonitorBackoff(schema, elapsedSeconds) {
996
996
  export function trySetQueueDeletionTime(schema, queues, seconds) {
997
997
  return trySetQueueTimestamp(schema, queues, 'maintain_on', seconds);
998
998
  }
999
- // The cron claim, which also answers with the timestamp it replaced. That timestamp is when an
1000
- // instance last ran a pass, so the pass reads it to find out how long scheduling was off, which is
1001
- // the window a schedule's `missed` policy catches up over. Null on a database no pass has ever run
1002
- // against, where there is no gap to catch up on.
999
+ // The cron claim, which also answers with the timestamp it replaced and how old that timestamp was.
1000
+ // The timestamp is when an instance last ran a pass, so the pass reads it to find out how long
1001
+ // scheduling was off, which is the window a schedule's `missed` policy catches up over. Null on a
1002
+ // database no pass has ever run against, where there is no gap to catch up on.
1003
1003
  //
1004
- // The prior value has to come from a CTE of its own: RETURNING sees the row as the UPDATE leaves
1004
+ // Both answers have to come from a CTE of their own: RETURNING sees the row as the UPDATE leaves
1005
1005
  // it, while a WITH sub-statement reads the snapshot the whole statement was planned against, so it
1006
- // sees the value the UPDATE is replacing. Zero rows still means another instance holds the claim,
1007
- // which is all the caller checked before.
1006
+ // sees the value the UPDATE is replacing.
1007
+ //
1008
+ // This answers on both outcomes rather than on a claim alone, which is why `claimed` is a column
1009
+ // and not the row count: an instance that was refused needs to know when the row it lost to comes
1010
+ // due, so its next attempt can be measured from the claim that beat it rather than from its own
1011
+ // failure. See Timekeeper.onCron(). Reading `claim` through EXISTS does not make the UPDATE
1012
+ // conditional - a data-modifying CTE always runs, exactly once, whether or not the outer query
1013
+ // reads it.
1008
1014
  export function trySetCronTime(schema, seconds) {
1009
1015
  return `
1010
1016
  WITH prior AS (
1011
- SELECT cron_on FROM ${schema}.version
1017
+ SELECT cron_on, EXTRACT( EPOCH FROM (${schema}.job_now() - cron_on) ) as elapsed FROM ${schema}.version
1012
1018
  ), claim AS (
1013
1019
  ${trySetTimestamp(schema, 'cron_on', seconds)}
1014
1020
  )
1015
- SELECT prior.cron_on as "priorCronOn" FROM prior, claim
1021
+ SELECT prior.cron_on as "priorCronOn", prior.elapsed as "elapsed", EXISTS (SELECT 1 FROM claim) as "claimed" FROM prior
1016
1022
  `;
1017
1023
  }
1018
1024
  export function trySetBamTime(schema, seconds) {
@@ -1318,8 +1324,8 @@ export function createIndexQueueStats(schema, noCoveringIndex = false) {
1318
1324
  // explicit `+00` timestamptz literals. This keeps partitioning correct regardless of the database
1319
1325
  // session TimeZone (a bare date literal like '2026-06-25' would otherwise be cast to timestamptz in
1320
1326
  // the session TZ, so rows written near UTC midnight could fall outside every existing partition).
1321
- // Computing the date in SQL (rather than interpolating new Date()) also keeps emitted DDL — including
1322
- // the v35 migration and exported create plans — deterministic and apply-time accurate.
1327
+ // Computing the date in SQL (rather than interpolating new Date()) also keeps emitted DDL, including
1328
+ // the v35 migration and exported create plans, deterministic and apply-time accurate.
1323
1329
  export function ensureQueueStatsPartitions(schema) {
1324
1330
  return `
1325
1331
  DO $$
@@ -1394,7 +1400,7 @@ export function insertQueueStats(schema, queues, noAdvisoryLocks) {
1394
1400
  return locked(schema, sql, 'queue-stats-insert', noAdvisoryLocks);
1395
1401
  }
1396
1402
  // Cheap single-row read of the cached counts the monitor maintains on the queue table. capturedOn
1397
- // is monitor_on — the moment those counts were last refreshed, or NULL if the queue has never been
1403
+ // is monitor_on. The moment those counts were last refreshed, or NULL if the queue has never been
1398
1404
  // monitored (so the caller knows to recompute rather than trust default-zero counts).
1399
1405
  //
1400
1406
  // monitorBackoff rides along because the caller must serve this cache even when it is stale while
@@ -1438,7 +1444,7 @@ export function getQueueStatsHistory(schema) {
1438
1444
  `;
1439
1445
  }
1440
1446
  // Per-bucket aggregate over a count column. The function name can't be a bind parameter, so it's
1441
- // interpolated — safe because the manager validates `aggregate` against this whitelist first. Every
1447
+ // interpolated, safe because the manager validates `aggregate` against this whitelist first. Every
1442
1448
  // result is cast back to int: it honors the int count contract (avg rounds) and keeps Postgres
1443
1449
  // returning the value as a JS number rather than a numeric string.
1444
1450
  const STATS_AGG = {
@@ -1450,8 +1456,8 @@ const STATS_AGG = {
1450
1456
  // bucket's counts with `aggregate`, so a wide window returns a manageable, representative sample
1451
1457
  // instead of just the newest `limit` raw rows.
1452
1458
  //
1453
- // mode 'bucket' — $5 is the bucket width in seconds (explicit resolution).
1454
- // mode 'auto' — $5 is maxDataPoints; the width is derived so the series fits in $5 points.
1459
+ // mode 'bucket', $5 is the bucket width in seconds (explicit resolution).
1460
+ // mode 'auto', $5 is maxDataPoints; the width is derived so the series fits in $5 points.
1455
1461
  // from/to sets the range, but they cannot exceed the data's own min/max values.
1456
1462
  //
1457
1463
  // The bucket key avoids date_bin() (PG14+): pg-boss supports PostgreSQL 13+ and CockroachDB/
@@ -1668,7 +1674,7 @@ export function fetchNextJob(options, noSkipLocked = false) {
1668
1674
  : '';
1669
1675
  // state DESC (retry > created in the enum) makes a retry job its key's head. job_i8
1670
1676
  // guarantees at most one job per key in active/retry/failed, and the NOT EXISTS blocker
1671
- // below rejects every sibling of a retry job — so if created_on picked the head, an older
1677
+ // below rejects every sibling of a retry job, so if created_on picked the head, an older
1672
1678
  // deferred job whose start_after has since arrived would claim the head slot while being
1673
1679
  // unfetchable, and the retry job (fetchable but not the head) would deadlock the key.
1674
1680
  const strictFifoHeadsCte = keyStrictFifo
@@ -1715,13 +1721,13 @@ export function fetchNextJob(options, noSkipLocked = false) {
1715
1721
  //
1716
1722
  // The two conditionals this replaces existed only to serve the `priority` and `orderByCreatedOn`
1717
1723
  // fetch options, which were requested as performance escapes from this sort. job_i11 now satisfies
1718
- // the ordering directly, so the escapes have nothing to escape — see the deprecation in
1724
+ // the ordering directly, so the escapes have nothing to escape. See the deprecation in
1719
1725
  // manager.fetch(). Dropping them also deletes two branches from the hottest SQL builder here.
1720
1726
  //
1721
1727
  // `id` goes too. It is a random uuid, so it never provided creation order: a batch insert shares
1722
1728
  // one ${schema}.job_now() and therefore ties on created_on, and those ties resolve today in random uuid order,
1723
1729
  // not insertion order. Without it the index satisfies the ordering outright rather than through
1724
- // an Incremental Sort — 0.097 -> 0.031 ms and 50 -> 5 buffers at limit=1 — and ties fall back to
1730
+ // an Incremental Sort (0.097 -> 0.031 ms and 50 -> 5 buffers at limit=1) and ties fall back to
1725
1731
  // index order, which tracks insertion order better than a uuid does.
1726
1732
  //
1727
1733
  // key_strict_fifo keeps its own id tiebreak in strict_fifo_heads, where DISTINCT ON does need a
@@ -1929,7 +1935,7 @@ export function restoreJobs(schema, table) {
1929
1935
  // A `startAfter` string is either an absolute date time or a delay expressed as a Postgres
1930
1936
  // interval. A trailing 'Z' has always marked a date time; a leading ISO 8601 calendar date
1931
1937
  // (YYYY-MM-DD) marks one as well, which is what lets the other 8601 zone designators through
1932
- // ('+00:00', '+05:30', '-08:00') along with zone-less and date-only strings — all of which
1938
+ // ('+00:00', '+05:30', '-08:00') along with zone-less and date-only strings, all of which
1933
1939
  // used to reach the interval cast and fail as 'invalid input syntax for type interval'.
1934
1940
  //
1935
1941
  // Recognition is only ever widened, so anything that resolves as an interval today still
@@ -2526,17 +2532,17 @@ export function insertDeadLetterJob(schema) {
2526
2532
  // Dead-letter redrive. Moves un-started jobs out of a dead-letter queue and
2527
2533
  // re-creates them as fresh jobs on their original source queue (or $2 destination override),
2528
2534
  // oldest-first, capped at $4. The JOIN in `candidates` only matches jobs whose destination queue
2529
- // exists, so legacy/orphaned jobs (NULL source_name, no override) are never deleted — they stay
2535
+ // exists, so legacy/orphaned jobs (NULL source_name, no override) are never deleted. They stay
2530
2536
  // in the DLQ rather than being lost. Re-created jobs get a new id, `created` state, retry_count 0,
2531
2537
  // cleared output, NULL source_*, and every queue-config column (retry/retention/policy/expiry/
2532
- // heartbeat/dead_letter) from the destination queue as it is configured now — per-job overrides
2538
+ // heartbeat/dead_letter) from the destination queue as it is configured now, per-job overrides
2533
2539
  // from the original send() are not preserved, since the DLQ copy never stored them. `dead_letter`
2534
2540
  // is the same value send() falls back to, so a second terminal failure re-enters the DLQ.
2535
2541
  // Job-identity columns (priority, singleton_key, group_id, group_tier) are carried over instead.
2536
2542
  //
2537
2543
  // The insert's ON CONFLICT DO NOTHING is load-bearing: a destination queue's short/stately policy
2538
2544
  // can still collide on (name, singleton_key) if two redriven jobs share a key (job_i1/job_i3), and
2539
- // dropping just that row — matching retried_jobs' ON CONFLICT DO NOTHING elsewhere — is preferable
2545
+ // dropping just that row (matching retried_jobs' ON CONFLICT DO NOTHING elsewhere) is preferable
2540
2546
  // to aborting the whole batch. The dropped job has already been deleted from the DLQ by the moved
2541
2547
  // CTE and is not restored.
2542
2548
  export function redriveJobs(schema, table) {
@@ -2711,7 +2717,7 @@ export function getQueueStats(schema, table, queues) {
2711
2717
  // Length of the recent-ready-count sliding window kept on queue.ready_history for the dashboard
2712
2718
  // sparkline. One sample is appended per monitor cycle (default 60s), so this is roughly the last
2713
2719
  // READY_HISTORY_SIZE minutes of trend. Sized to comfortably render the sparkline (the widest is the
2714
- // ~160px detail card) without over-collecting — more points than pixels add nothing visible.
2720
+ // ~160px detail card) without over-collecting, more points than pixels add nothing visible.
2715
2721
  export const READY_HISTORY_SIZE = 60;
2716
2722
  /* eslint-disable no-restricted-syntax -- how long this transaction has held its snapshot: real elapsed time, not job time */
2717
2723
  const PIN_SECONDS_SQL = 'EXTRACT(EPOCH FROM (clock_timestamp() - transaction_timestamp()))::float8';
@@ -2889,7 +2895,7 @@ function advisoryLock(schema, key) {
2889
2895
  // winning the race to duplicate a scan nobody asked for costs the job table.
2890
2896
  //
2891
2897
  // The guard is a one-time filter, so a lost race skips the scan rather than running and discarding
2892
- // it — verified on the plan: `Parallel Seq Scan on job (never executed)`. It also has to gate the
2898
+ // it, verified on the plan: `Parallel Seq Scan on job (never executed)`. It also has to gate the
2893
2899
  // UPDATE, not just the aggregate: an empty stats CTE still LEFT JOINs, and the COALESCE(…, 0) would
2894
2900
  // write every count to zero.
2895
2901
  function tryAdvisoryLock(schema, key, noAdvisoryLocks) {
@@ -3004,48 +3010,56 @@ export function getNextBamCommand(schema, { useLiveness = false } = {}) {
3004
3010
  // a stuck in_progress row is reclaimed purely on the 24h fallback. No CONCURRENTLY healing, so no
3005
3011
  // reclaimed flag is emitted (bam.ts skips healing when noIndexProgressView is set anyway).
3006
3012
  return `
3007
- UPDATE ${schema}.bam
3008
- SET status = 'in_progress', started_on = ${schema}.job_now()
3009
- WHERE id = (
3010
- SELECT id FROM ${schema}.bam
3013
+ WITH candidate AS (
3014
+ SELECT c.id, c.status AS prior_status, c.started_on AS prior_started_on
3015
+ FROM ${schema}.bam c
3011
3016
  WHERE (
3012
- status IN ('pending', 'failed')
3013
- OR (status = 'in_progress' AND started_on < ${schema}.job_now() - interval '${BAM_STALE_SECONDS} seconds')
3017
+ c.status IN ('pending', 'failed')
3018
+ OR (c.status = 'in_progress' AND c.started_on < ${schema}.job_now() - interval '${BAM_STALE_SECONDS} seconds')
3014
3019
  )
3015
3020
  AND NOT EXISTS (
3016
- SELECT 1 FROM ${schema}.bam
3017
- WHERE status = 'in_progress' AND started_on >= ${schema}.job_now() - interval '${BAM_STALE_SECONDS} seconds'
3021
+ SELECT 1 FROM ${schema}.bam g
3022
+ WHERE g.status = 'in_progress' AND g.started_on >= ${schema}.job_now() - interval '${BAM_STALE_SECONDS} seconds'
3018
3023
  )
3019
- ORDER BY (status != 'pending'), created_on
3024
+ ORDER BY (c.status != 'pending'), c.created_on
3020
3025
  LIMIT 1
3021
3026
  )
3022
- RETURNING id, name, version, status, queue, table_name as "table", command, error,
3023
- created_on as "createdOn", started_on as "startedOn", completed_on as "completedOn"
3027
+ UPDATE ${schema}.bam b
3028
+ SET status = 'in_progress', started_on = ${schema}.job_now()
3029
+ FROM candidate
3030
+ WHERE b.id = candidate.id
3031
+ AND b.status = candidate.prior_status
3032
+ AND b.started_on IS NOT DISTINCT FROM candidate.prior_started_on
3033
+ RETURNING b.id, b.name, b.version, b.status, b.queue, b.table_name as "table", b.command, b.error,
3034
+ b.created_on as "createdOn", b.started_on as "startedOn", b.completed_on as "completedOn",
3035
+ (candidate.prior_status <> 'pending') as reattempt,
3036
+ candidate.prior_status as "priorStatus", candidate.prior_started_on::text as "priorStartedOn",
3037
+ b.started_on::text as "claimedStartedOn"
3024
3038
  `;
3025
3039
  }
3026
3040
  // Native-Postgres liveness path. An in_progress row counts as "stale" (reclaimable) when it is past
3027
3041
  // the grace window AND no backend is actually building its index right now. The same predicate,
3028
- // negated, defines a genuinely-live command that must still block the queue — so a running build is
3042
+ // negated, defines a genuinely-live command that must still block the queue, so a running build is
3029
3043
  // NEVER reclaimed (no matter how long it runs), and a dead one recovers within the grace window.
3030
3044
  // There is deliberately no 24h absolute cap here: liveBuild=true always means a build is in flight, so
3031
3045
  // capping on elapsed time would reclaim a genuinely-running build and start a second
3032
- // CREATE INDEX CONCURRENTLY on the same index — the exact double-build this path exists to prevent.
3046
+ // CREATE INDEX CONCURRENTLY on the same index. The exact double-build this path exists to prevent.
3033
3047
  // (The timeout-only path's BAM_STALE_SECONDS fallback covers engines with no way to detect liveness.)
3034
3048
  //
3035
3049
  // liveBuild(tableCol): is a CREATE INDEX CONCURRENTLY actively building this table's index right now?
3036
- // Detected via pg_locks, NOT pg_stat_progress_create_index — and that choice is load-bearing for
3050
+ // Detected via pg_locks, NOT pg_stat_progress_create_index, and that choice is load-bearing for
3037
3051
  // multi-instance safety. pg_stat_progress_* is filtered to the querying role's OWN backends (only a
3038
3052
  // superuser or a member of pg_read_all_stats sees another role's builds), so a progress-view check
3039
3053
  // silently reads a peer's live build as "dead" whenever pg-boss instances connect under different DB
3040
- // roles — and the heal step (bamHealProbe/bamHealDrop in bam.ts) would then DROP INDEX CONCURRENTLY a
3054
+ // roles, and the heal step (bamHealProbe/bamHealDrop in bam.ts) would then DROP INDEX CONCURRENTLY a
3041
3055
  // live index mid-build, racing the builder into a double CREATE. pg_locks, by contrast, is cluster-wide
3042
3056
  // and visible to every role (verified empirically). CREATE INDEX CONCURRENTLY holds a
3043
3057
  // ShareUpdateExclusiveLock on the target table for the ENTIRE build and releases it the instant the
3044
3058
  // statement finishes or the backend dies, so a granted SUExclusive lock on the row's table is a
3045
- // crash-safe, role-agnostic "build in flight" signal — instances may run under different roles with no
3059
+ // crash-safe, role-agnostic "build in flight" signal, instances may run under different roles with no
3046
3060
  // loss of safety. Ordinary queue DML never takes SUExclusive (it uses AccessShare/RowShare/
3047
3061
  // RowExclusive), so it can't false-trigger; a concurrent autovacuum/ANALYZE on the same table DOES take
3048
- // SUExclusive and reads as "live", but that false positive is in the SAFE direction — it only briefly
3062
+ // SUExclusive and reads as "live", but that false positive is in the SAFE direction. It only briefly
3049
3063
  // DEFERS a reclaim, never drops a live index. Scoped to the current database (l.database) because
3050
3064
  // pg_locks is cluster-wide while relation OIDs are only unique per database. Correlating on the table
3051
3065
  // is enough because the queue runs only one in_progress command at a time.
@@ -3077,7 +3091,7 @@ export function getNextBamCommand(schema, { useLiveness = false } = {}) {
3077
3091
  // 'pending' rows have nothing to heal.
3078
3092
  return `
3079
3093
  WITH candidate AS (
3080
- SELECT c.id, c.status AS prior_status
3094
+ SELECT c.id, c.status AS prior_status, c.started_on AS prior_started_on
3081
3095
  FROM ${schema}.bam c
3082
3096
  WHERE (
3083
3097
  c.status IN ('pending', 'failed')
@@ -3095,9 +3109,13 @@ export function getNextBamCommand(schema, { useLiveness = false } = {}) {
3095
3109
  SET status = 'in_progress', started_on = ${schema}.job_now()
3096
3110
  FROM candidate
3097
3111
  WHERE b.id = candidate.id
3112
+ AND b.status = candidate.prior_status
3113
+ AND b.started_on IS NOT DISTINCT FROM candidate.prior_started_on
3098
3114
  RETURNING b.id, b.name, b.version, b.status, b.queue, b.table_name as "table", b.command, b.error,
3099
3115
  b.created_on as "createdOn", b.started_on as "startedOn", b.completed_on as "completedOn",
3100
- (candidate.prior_status <> 'pending') as reattempt
3116
+ (candidate.prior_status <> 'pending') as reattempt,
3117
+ candidate.prior_status as "priorStatus", candidate.prior_started_on::text as "priorStartedOn",
3118
+ b.started_on::text as "claimedStartedOn"
3101
3119
  `;
3102
3120
  }
3103
3121
  // Derives the drop-then-rebuild heal step for a re-attempted BAM index build: an interrupted
@@ -3114,8 +3132,8 @@ export function bamHealDrop(schema, command) {
3114
3132
  }
3115
3133
  // Probe run before bamHealDrop: returns `invalid = true` only when the index the re-attempted command
3116
3134
  // would build already exists AND is INVALID (an interrupted CREATE INDEX CONCURRENTLY left a stub).
3117
- // A build that actually finished but whose BAM row was never marked completed — e.g. a graceful stop
3118
- // landed between the CREATE succeeding and markCompleted — leaves a VALID index; dropping that would
3135
+ // A build that actually finished but whose BAM row was never marked completed, e.g. a graceful stop
3136
+ // landed between the CREATE succeeding and markCompleted, leaves a VALID index; dropping that would
3119
3137
  // tear down a live production index for the whole rebuild window, so the caller must NOT heal it (the
3120
3138
  // re-run's own IF NOT EXISTS then no-ops and just marks the row done). Returns null for non-index
3121
3139
  // commands and for an absent index (no row), where there is nothing to drop. Mirrors bamHealDrop's
@@ -3132,10 +3150,39 @@ export function bamHealProbe(schema, command) {
3132
3150
  WHERE n.nspname = '${resolveSchemaName(schema).replace(SINGLE_QUOTE_REGEX, "''")}' AND c.relname = '${match[1].replace(SINGLE_QUOTE_REGEX, "''")}'
3133
3151
  `;
3134
3152
  }
3153
+ // Undoes a claim that never ran: hands the row back exactly as getNextBamCommand found it, restoring
3154
+ // both the status and the started_on the claim overwrote. Used when a stop lands between the claim
3155
+ // UPDATE returning and the runner picking the command up. Leaving the row in_progress instead would
3156
+ // block every other BAM command until it went stale - BAM_LIVENESS_GRACE_SECONDS on native Postgres,
3157
+ // BAM_STALE_SECONDS (24 hours) on the timeout-only backends - and restoring started_on is what makes a
3158
+ // released stale-in_progress row immediately reclaimable again rather than starting that clock over.
3159
+ export function releaseBamCommand(schema, id, priorStatus, priorStartedOn, claimedStartedOn) {
3160
+ // priorStartedOn arrives as the text rendering of the original timestamptz (the claim casts it in
3161
+ // SQL rather than round-tripping a JS Date), so it carries its offset and full precision back.
3162
+ const startedOn = priorStartedOn == null
3163
+ ? 'NULL'
3164
+ : `'${priorStartedOn.replace(SINGLE_QUOTE_REGEX, "''")}'::timestamptz`;
3165
+ // Compare-and-swap on the claim this runner actually took, not on the id alone. The timeout-only
3166
+ // claim has no SKIP LOCKED (see getNextBamCommand), so two overlapping claims can both return the
3167
+ // same row - verified: both get rowCount 1 and the same prior_status. Releasing on the id alone
3168
+ // would then reset a row a peer is actively building back to 'pending', and the next poll would
3169
+ // start a second CREATE INDEX CONCURRENTLY on the same index. Matching started_on makes the release
3170
+ // a no-op unless the claim is still ours.
3171
+ return `
3172
+ UPDATE ${schema}.bam
3173
+ SET status = '${priorStatus.replace(SINGLE_QUOTE_REGEX, "''")}', started_on = ${startedOn}
3174
+ WHERE id = '${id}'
3175
+ AND status = 'in_progress'
3176
+ AND started_on = '${claimedStartedOn.replace(SINGLE_QUOTE_REGEX, "''")}'::timestamptz
3177
+ `;
3178
+ }
3135
3179
  export function setBamCompleted(schema, id) {
3180
+ // error is cleared: a row reaching 'completed' is commonly a retry of a prior 'failed' attempt, and
3181
+ // getBamEntries() returns error for every status - a stale message would keep reading as a failure
3182
+ // on a command that succeeded.
3136
3183
  return `
3137
3184
  UPDATE ${schema}.bam
3138
- SET status = 'completed', completed_on = ${schema}.job_now()
3185
+ SET status = 'completed', completed_on = ${schema}.job_now(), error = NULL
3139
3186
  WHERE id = '${id}'
3140
3187
  `;
3141
3188
  }
@@ -3176,7 +3223,7 @@ export function jobCommonExists(schema) {
3176
3223
  export function getManagedQueuePartitions(schema) {
3177
3224
  return `SELECT table_name as "table", policy FROM ${schema}.queue WHERE partition = true`;
3178
3225
  }
3179
- // The CREATE INDEX command text of every BAM row not yet completed — used to tell a genuinely-missing
3226
+ // The CREATE INDEX command text of every BAM row not yet completed, used to tell a genuinely-missing
3180
3227
  // index apart from one an async build is still working on (or retrying after a failure).
3181
3228
  export function getIncompleteBamCommands(schema) {
3182
3229
  return `SELECT command FROM ${schema}.bam WHERE status <> 'completed'`;
@@ -3199,7 +3246,7 @@ const POLICY_JOB_INDEXES = {
3199
3246
  8: QUEUE_POLICIES.key_strict_fifo,
3200
3247
  10: QUEUE_POLICIES.key_strict_fifo
3201
3248
  };
3202
- // job_iN indexes with no policy gate — created on every job table regardless of policy
3249
+ // job_iN indexes with no policy gate, created on every job table regardless of policy
3203
3250
  // (throttle i4, fetch i11, group-concurrency i7, blocking i9). 5 is absent, not missing: the fetch
3204
3251
  // index was replaced in v40 and the retired number is not reused.
3205
3252
  const BASE_JOB_INDEXES = [4, 7, 9, 11];
@@ -3214,7 +3261,7 @@ function applyManifestSchema(text, schema) {
3214
3261
  return text.split(schemaManifest.schemaToken).join(schema);
3215
3262
  }
3216
3263
  // The job_state enum values in declaration order, from the manifest (both sections carry the same enum).
3217
- // Order is significant — the numeric base type makes created < retry < … < failed load-bearing.
3264
+ // Order is significant. The numeric base type makes created < retry < … < failed load-bearing.
3218
3265
  export const EXPECTED_JOB_STATES = schemaManifest.partitioned.enum;
3219
3266
  // The tables pg-boss expects to exist. The manifest lists the fixed tables plus job (and job_common in
3220
3267
  // partitioned mode); per-queue partition tables are dynamic, so they are appended from the live set.
@@ -3228,7 +3275,7 @@ export function expectedManagedTables(schema, partitioned, partitions = []) {
3228
3275
  // The columns pg-boss expects on each managed table, taken from the manifest (introspected column type,
3229
3276
  // nullability, and default). Fixed tables carry the full defaults + types maps so column defaults, data
3230
3277
  // types, and nullability are diffed; the job table (shared by job_common and every per-queue partition
3231
- // in partitioned mode) is name-only — its FKs are profile-dependent and keep_until's interval default is
3278
+ // in partitioned mode) is name-only. Its FKs are profile-dependent and keep_until's interval default is
3232
3279
  // not worth pinning per-partition. A table with no live columns is skipped by the diff, so listing one
3233
3280
  // that does not exist yet is harmless.
3234
3281
  export function expectedManagedColumns(schema, partitioned, partitions = []) {
@@ -3296,7 +3343,7 @@ export function expectedManagedFunctions(schema, partitioned, options = {}) {
3296
3343
  // `partitioned` reflects the live database (job_common present ⇒ partitioned architecture), so this
3297
3344
  // needs no boss config. The manifest holds every fixed index (the static ones plus the job_iN set on
3298
3345
  // job/job_common); each per-queue partition is dynamic, so its indexes are templated here from the
3299
- // job_common set — the base indexes always, plus the one for the queue's policy. keys/predicate/
3346
+ // job_common set. The base indexes always, plus the one for the queue's policy. keys/predicate/
3300
3347
  // definition are derived from the catalog-canonical pg_get_indexdef the manifest stores.
3301
3348
  export function expectedManagedIndexes(schema, partitioned, partitions = []) {
3302
3349
  const managed = (name, table, indexdef) => {
@@ -3330,13 +3377,13 @@ export function expectedManagedIndexes(schema, partitioned, partitions = []) {
3330
3377
  //
3331
3378
  // Autovacuum reclaims heap space but never returns btree pages to the OS, so a job index sizes
3332
3379
  // itself to the largest backlog its queue has ever held and stays there. A drained 1M-job peak
3333
- // leaves job_common_i11 at 38 MB and job_common_pkey at 51 MB holding 1k live rows — and every
3380
+ // leaves job_common_i11 at 38 MB and job_common_pkey at 51 MB holding 1k live rows, and every
3334
3381
  // subsequent vacuum with dead tuples to clean does a full physical scan of every one of those
3335
3382
  // pages (measured: 17,969 buffer hits + 3,892 reads vs 750 hits / 0 reads after a rebuild), which
3336
3383
  // is what drains IO burst credits on managed Postgres.
3337
3384
  //
3338
- // The bloat is a high-water mark rather than an unbounded leak — empty pages ARE recycled by later
3339
- // inserts — so the trigger is density, not elapsed time.
3385
+ // The bloat is a high-water mark rather than an unbounded leak, empty pages ARE recycled by later
3386
+ // inserts, so the trigger is density, not elapsed time.
3340
3387
  export const REINDEX_DEFAULTS = {
3341
3388
  // 1 MB. Below this the absolute waste is irrelevant and reltuples/relpages is noisy.
3342
3389
  minPages: 128,
@@ -3365,8 +3412,8 @@ export const REINDEX_DEFAULTS = {
3365
3412
  // int4 is not an option - the index that exceeds maxIndexBytes is exactly the one that overflows it.
3366
3413
  // float8 is exact to 2^53 bytes, and reltuples is a float4 estimate to begin with.
3367
3414
  // The pages the index's *live* entries actually need, from the average width of what it indexes.
3368
- // pg_stats carries that per column, and — the part that matters here, since i1-i4 and i6 key on
3369
- // `COALESCE(singleton_key, '')` — ANALYZE also collects it for an index's expression columns, keyed
3415
+ // pg_stats carries that per column, and. The part that matters here, since i1-i4 and i6 key on
3416
+ // `COALESCE(singleton_key, '')`, ANALYZE also collects it for an index's expression columns, keyed
3370
3417
  // by the index relation. Plain attnums read the table's stats, attnum 0 (an expression) falls back
3371
3418
  // to the index's own. 20 bytes covers the btree tuple header and line pointer.
3372
3419
  const INDEX_WIDTH_JOIN = `LEFT JOIN LATERAL (
@@ -3409,15 +3456,15 @@ function jobTableScope(schema, tables) {
3409
3456
  * healthy one by three orders of magnitude on their own.
3410
3457
  *
3411
3458
  * PostgreSQL-only (including PGlite, where both the bloat and REINDEX CONCURRENTLY behave normally).
3412
- * Callers must gate on noReindex — see the note in boss.ts #reindex.
3459
+ * Callers must gate on noReindex. See the note in boss.ts #reindex.
3413
3460
  *
3414
3461
  * `owned` is returned rather than filtered on, so an installation whose role cannot reindex still
3415
- * gets told what is bloated. Only leaf indexes (relkind 'i') are candidates — pg-boss creates
3462
+ * gets told what is bloated. Only leaf indexes (relkind 'i') are candidates. pg-boss creates
3416
3463
  * i1..i10 directly on each partition, and a partitioned index (relkind 'I') cannot be reindexed
3417
3464
  * except through its leaves anyway.
3418
3465
  *
3419
3466
  * Caveat: relpages/reltuples are refreshed by VACUUM/ANALYZE, so they go stale where autovacuum is
3420
- * disabled for the table. That is acceptable — the entire failure mode assumes autovacuum runs.
3467
+ * disabled for the table. That is acceptable. The entire failure mode assumes autovacuum runs.
3421
3468
  */
3422
3469
  export function getBloatedIndexes(schema, tables, options) {
3423
3470
  // These are interpolated into the statement, and supervise()/getReindexCommands() accept them
@@ -3454,7 +3501,7 @@ export function getBloatedIndexes(schema, tables, options) {
3454
3501
  `;
3455
3502
  }
3456
3503
  /**
3457
- * Every owned leaf index on the job tables, ignoring the density gate — the target list for
3504
+ * Every owned leaf index on the job tables, ignoring the density gate. The target list for
3458
3505
  * `{ force: true }`.
3459
3506
  */
3460
3507
  export function getJobIndexes(schema, tables) {
@@ -3528,13 +3575,13 @@ function quoteIdentifier(name) {
3528
3575
  *
3529
3576
  * `pg_stat_activity.backend_xmin` is readable by an unprivileged role (it is not one of the columns
3530
3577
  * restricted to the owning user), as are the other three views, so the degraded path is defensive
3531
- * rather than expected — a managed provider may still revoke them.
3578
+ * rather than expected. A managed provider may still revoke them.
3532
3579
  */
3533
3580
  /* eslint-disable no-restricted-syntax -- these measure real backend and vacuum age against pg_stat_* timestamps Postgres wrote; a fake clock would compare two different clocks */
3534
3581
  export const XMIN_HORIZON_SOURCES = {
3535
3582
  // Restricted to backends whose transaction was already open when the failed vacuum ran ($1).
3536
3583
  // Every backend executing a query advertises a backend_xmin, including the one asking this
3537
- // question, so presence alone is not evidence — the ordering against a vacuum that reclaimed
3584
+ // question, so presence alone is not evidence. The ordering against a vacuum that reclaimed
3538
3585
  // nothing is. Compared as timestamps inside one statement rather than as two ages read from two
3539
3586
  // queries a moment apart, which would let a connection that started after the vacuum qualify.
3540
3587
  //
@@ -3543,7 +3590,7 @@ export const XMIN_HORIZON_SOURCES = {
3543
3590
  // ordinary role only pid, application_name, usename and backend_xmin for a backend owned by a
3544
3591
  // *different* role; xact_start reads NULL and query reads '<insufficient privilege>' (measured on
3545
3592
  // PG18). Since `NULL < $1` is NULL, the strict form silently discarded exactly the holder an
3546
- // operator most needs named — another application's analytical query — leaving every source NULL,
3593
+ // operator most needs named (another application's analytical query) leaving every source NULL,
3547
3594
  // no holder attributable, and the warning withheld. Admitting them costs a possible false
3548
3595
  // positive from a young foreign backend, which in practice loses the max(age()) to any real
3549
3596
  // holder, and opaqueBackends below reports how many could not be timed.
@@ -3560,7 +3607,7 @@ export const XMIN_HORIZON_QUERY_SOURCES = Object.keys(XMIN_HORIZON_SOURCES);
3560
3607
  * `age()` measures transactions elapsed since the pinned xid, not wall time, which is the metric
3561
3608
  * that matters: dead tuples accumulate per transaction, so a horizon pinned across an idle night
3562
3609
  * costs nothing while the same age on a busy queue is a backlog of rows autovacuum cannot reclaim.
3563
- * Measured directly — a REPEATABLE READ holder open on an idle database reports 0, and 2,000 after
3610
+ * Measured directly. A REPEATABLE READ holder open on an idle database reports 0, and 2,000 after
3564
3611
  * 2,000 transactions with the same holder still open.
3565
3612
  *
3566
3613
  * Also returns the oldest in-transaction backend's wall-clock age, purely so the warning can say
@@ -3687,7 +3734,7 @@ export function dropIndexConcurrently(schema, name) {
3687
3734
  * The statement list an operator runs by hand: every stale stub dropped first, then the rebuilds.
3688
3735
  *
3689
3736
  * Stubs are emitted whole rather than paired to the index they came from. Postgres does not append
3690
- * `_ccnew` — it truncates the *base* so the result fits in 63 bytes, so `j<sha224>_i11` (61 chars)
3737
+ * `_ccnew`. It truncates the *base* so the result fits in 63 bytes, so `j<sha224>_i11` (61 chars)
3691
3738
  * leaves behind `j<sha224>__ccnew` with the `i11` gone, and a prefix match never fires on a
3692
3739
  * partitioned queue (every partition table name is `'j' || sha224(queue_name)`, 57 chars). Dropping
3693
3740
  * them unconditionally is also what the background pass does: an invalid stub is dead weight that
@@ -31,6 +31,28 @@ declare class Timekeeper extends EventEmitter implements types.EventsMixin {
31
31
  private scheduleSkewMonitor;
32
32
  cacheClockSkew(): Promise<void>;
33
33
  onCron(): Promise<void>;
34
+ /**
35
+ * How long an instance whose claim was refused waits before trying again: until the row it lost
36
+ * to comes due, plus a beat.
37
+ *
38
+ * The beat keeps the retry off the due instant itself, where the holder's own attempt lands and
39
+ * where the row can still be a microsecond short of the interval. A quarter of the interval,
40
+ * capped at a second, so it stays a beat at every configurable interval instead of doubling the
41
+ * smallest one.
42
+ *
43
+ * No jitter. What it would buy is a spread the retries already have: each is measured from its
44
+ * own statement's snapshot and its own round trip, so several instances do not land on one
45
+ * instant, and a refused claim is a single-row UPDATE whose predicate fails. What it would cost
46
+ * is the one thing in this path a TestClock cannot drive to a known instant, which is what the
47
+ * regression test for this needs.
48
+ *
49
+ * `elapsed` is how old the row was in the snapshot the claim read, so a winner that commits
50
+ * between that snapshot and this statement's UPDATE leaves it measured against the previous
51
+ * stamp: the retry then comes back early, is refused, and measures again. Self-correcting, and it
52
+ * costs one extra statement in a race that has to happen inside a round trip. A null reads as a
53
+ * full interval, which is the row a pass has never stamped - a state no claim is refused in.
54
+ */
55
+ private claimRetryDelay;
34
56
  /**
35
57
  * `priorCronOn` is when an instance last ran a pass, as the claim in onCron() read it off the row
36
58
  * it advanced. Null on a database no pass has run against, and left null by a caller that does
@@ -63,14 +85,18 @@ declare class Timekeeper extends EventEmitter implements types.EventsMixin {
63
85
  * The gap is (lastPass, windowStart]: older than the due window, so no pass has sent it, and
64
86
  * newer than the moment an instance last ran a pass, so no pass has skipped it either. A pass
65
87
  * claims `cronMonitorIntervalSeconds` after the one before it plus the round trip the claim
66
- * itself cost, 45 seconds and change at the configurable ceiling, against a 60-second window, so
67
- * the range is empty while passes keep running and fills up when they stop: a deployment that is
68
- * down, between deploys, or running with scheduling switched off.
69
- *
70
- * That the spacing is an interval and not two of them is the claim timer's doing: it schedules
71
- * each attempt from the moment the previous claim stamped the row rather than on a grid, so a
72
- * statement that lands quickly cannot measure the row short of the interval and lose the claim
73
- * for a whole one. See ClaimTimer.
88
+ * itself cost and the beat a refused instance waits past the due moment, 46 seconds and change at
89
+ * the configurable ceiling, against a 60-second window, so the range is empty while passes keep
90
+ * running and fills up when they stop: a deployment that is down, between deploys, or running
91
+ * with scheduling switched off.
92
+ *
93
+ * That the spacing is an interval and not two of them takes both halves of onCron()'s answer. The
94
+ * claim timer schedules a winner's next attempt from the moment its claim stamped the row rather
95
+ * than on a grid, so a statement that lands quickly cannot measure the row short of the interval
96
+ * and lose the claim for a whole one. A loser schedules its next attempt for when the row it lost
97
+ * to comes due rather than an interval after its own failure, so the deployment keeps the spacing
98
+ * when the instance holding the claim stops rather than waiting out that instance's phase as
99
+ * well. See ClaimTimer and claimRetryDelay().
74
100
  *
75
101
  * The most recent occurrence rather than all of them, which is the whole of what `once` promises:
76
102
  * a job carries the schedule's `data` and nothing else, so a job per missed occurrence would be
@@ -1 +1 @@
1
- {"version":3,"file":"timekeeper.d.ts","sourceRoot":"","sources":["../src/timekeeper.ts"],"names":[],"mappings":"AAEA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAKvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAGnC,eAAO,MAAM,MAAM;;CAElB,CAAA;AAwBD,kGAAkG;AAClG,eAAO,MAAM,iBAAiB,OAAO,CAAA;AA8JrC,cAAM,UAAW,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;IAChE,EAAE,EAAE,KAAK,CAAC,SAAS,CAAA;IACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B,CAAA;IACxC,OAAO,EAAE,OAAO,CAAA;IAEhB,OAAO,CAAC,OAAO,CAAO;IACtB,OAAO,CAAC,gBAAgB,CAA+B;IACvD,OAAO,CAAC,gBAAgB,CAAqC;IAC7D,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,aAAa,CAAQ;IAS7B,OAAO,CAAC,eAAe,CAAoB;IAE3C,SAAS,SAAI;IACb,MAAM;;;;MAAS;gBAEF,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAQ5F,IAAI,YAAY,IAAK,OAAO,CAE3B;IAMD,OAAO,KAAK,YAAY,GAEvB;IAED,OAAO,KAAK,cAAc,GASzB;IAEK,KAAK;IA4BL,IAAI;IAmCV,OAAO,CAAC,mBAAmB;IAUrB,cAAc;IA8Cd,MAAM;IAkCZ;;;;;OAKG;IACG,IAAI,CAAE,WAAW,GAAE,OAAc;IA0IvC,YAAY,CAAE,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,GAAE,KAAK,CAAC,YAAwC;IAIlG;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,cAAc;IAgBtB;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,OAAO,CAAC,gBAAgB;IAgBxB;;;;;;;OAOG;IACH,OAAO,CAAC,sBAAsB;IAgB9B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,eAAe;IAkBvB,OAAO,CAAC,iBAAiB;YAQX,QAAQ;YAsDR,aAAa;IAerB,YAAY,CAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;IAiBrE,WAAW,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC;IAe1E;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAYxB;;;;;;;;;;;;;;;;OAgBG;IACH,eAAe,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,KAAK,CAAC,sBAA2B,GAAG,IAAI,EAAE;IA0C5E,QAAQ,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,KAAK,CAAC,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IA6CzG,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,IAAI,CAAC;CAIzD;AAED,eAAe,UAAU,CAAA"}
1
+ {"version":3,"file":"timekeeper.d.ts","sourceRoot":"","sources":["../src/timekeeper.ts"],"names":[],"mappings":"AAEA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAKvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAGnC,eAAO,MAAM,MAAM;;CAElB,CAAA;AAwBD,kGAAkG;AAClG,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAgLrC,cAAM,UAAW,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;IAChE,EAAE,EAAE,KAAK,CAAC,SAAS,CAAA;IACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B,CAAA;IACxC,OAAO,EAAE,OAAO,CAAA;IAEhB,OAAO,CAAC,OAAO,CAAO;IACtB,OAAO,CAAC,gBAAgB,CAA+B;IACvD,OAAO,CAAC,gBAAgB,CAAqC;IAC7D,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,aAAa,CAAQ;IAS7B,OAAO,CAAC,eAAe,CAAoB;IAE3C,SAAS,SAAI;IACb,MAAM;;;;MAAS;gBAEF,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAQ5F,IAAI,YAAY,IAAK,OAAO,CAE3B;IAMD,OAAO,KAAK,YAAY,GAEvB;IAED,OAAO,KAAK,cAAc,GASzB;IAEK,KAAK;IA4BL,IAAI;IAmCV,OAAO,CAAC,mBAAmB;IAUrB,cAAc;IA8Cd,MAAM;IAgDZ;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,eAAe;IAQvB;;;;;OAKG;IACG,IAAI,CAAE,WAAW,GAAE,OAAc;IA0IvC,YAAY,CAAE,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,GAAE,KAAK,CAAC,YAAwC;IAIlG;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,cAAc;IAgBtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,OAAO,CAAC,gBAAgB;IAgBxB;;;;;;;OAOG;IACH,OAAO,CAAC,sBAAsB;IAgB9B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,eAAe;IAkBvB,OAAO,CAAC,iBAAiB;YAQX,QAAQ;YAsDR,aAAa;IAerB,YAAY,CAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;IAiBrE,WAAW,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC;IAe1E;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAYxB;;;;;;;;;;;;;;;;OAgBG;IACH,eAAe,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,KAAK,CAAC,sBAA2B,GAAG,IAAI,EAAE;IA0C5E,QAAQ,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,KAAK,CAAC,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IA6CzG,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,IAAI,CAAC;CAIzD;AAED,eAAe,UAAU,CAAA"}