pg-boss 12.34.0 → 12.35.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
@@ -56,8 +56,6 @@ export const SCHEDULE_KINDS = Object.freeze({
56
56
  cron: 'cron',
57
57
  rrule: 'rrule'
58
58
  });
59
- /** The kind column's domain, for the CHECK on the table and the migration that adds it. */
60
- export const SCHEDULE_KIND_CHECK = `kind IN ('${SCHEDULE_KINDS.cron}', '${SCHEDULE_KINDS.rrule}')`;
61
59
  /**
62
60
  * What a schedule does about occurrences that came due while no cron pass ran.
63
61
  *
@@ -242,8 +240,7 @@ export function disableClockOverride() {
242
240
  // The stored source of the clock function, for spotting an override a killed test run left behind.
243
241
  // prosrc rather than pg_get_functiondef: the latter is unsupported on CockroachDB, and the caller
244
242
  // only needs to know whether the body reads CLOCK_OVERRIDE_SETTING, not to diff it. CockroachDB
245
- // rewrites what it stores, so this must never be compared against CLOCK_FUNCTION_BODY for equality -
246
- // see clockFunctionIsOverridden.
243
+ // rewrites what it stores, so this must never be compared against CLOCK_FUNCTION_BODY for equality.
247
244
  export function getClockFunctionSource(schema) {
248
245
  return `
249
246
  SELECT p.prosrc AS source
@@ -287,7 +284,7 @@ export function createClockFunction(schema, options = {}) {
287
284
  // monitor_backoff_on the odd one out - a deadline rather than a last-run stamp. Before it, no
288
285
  // instance may start another queue-stats aggregate, so autovacuum gets a
289
286
  // horizon-free window it cannot miss. Null on a database that has never
290
- // needed one; see setMonitorBackoff for what writes it and how long it is.
287
+ // needed one.
291
288
  function createTableVersion(schema) {
292
289
  return `
293
290
  CREATE TABLE ${schema}.version (
@@ -302,9 +299,12 @@ function createTableVersion(schema) {
302
299
  }
303
300
  // Two stamps, not one. monitor_claim_on is the interval claim that decides which instance runs a
304
301
  // monitor pass; monitor_on is when this queue's counts were actually written, and is stamped only by
305
- // the aggregate that wrote them (see cacheQueueStats). Splitting them is what lets a pass be claimed
306
- // and then skip the aggregate - because the vacuum backoff is in force, or because another instance
307
- // holds the stats try-lock - without capturedOn claiming a freshness the counts do not have.
302
+ // the aggregate that wrote them. Splitting them is what lets a pass be claimed and then skip the
303
+ // aggregate - because the vacuum backoff is in force, or because another instance holds the stats
304
+ // try-lock - without capturedOn claiming a freshness the counts do not have.
305
+ // created_delta / completed_delta / failed_delta are not gauges like the counts beside them: they
306
+ // are how many jobs went through between the previous monitor pass and the latest one. delta_on and
307
+ // delta_seconds are the window those three cover, null until a pass counts.
308
308
  /* eslint-disable no-restricted-syntax -- column defaults stay on the real clock: every pg-boss write names its timestamps through job_now() */
309
309
  function createTableQueue(schema) {
310
310
  return `
@@ -328,6 +328,11 @@ function createTableQueue(schema) {
328
328
  active_count int NOT NULL default 0,
329
329
  failed_count int NOT NULL default 0,
330
330
  total_count int NOT NULL default 0,
331
+ created_delta int NOT NULL default 0,
332
+ completed_delta int NOT NULL default 0,
333
+ failed_delta int NOT NULL default 0,
334
+ delta_on timestamp with time zone,
335
+ delta_seconds int,
331
336
  ready_history int[] NOT NULL default '{}',
332
337
  heartbeat_seconds int,
333
338
  notify bool NOT NULL DEFAULT false,
@@ -355,7 +360,7 @@ function createTableSchedule(schema) {
355
360
  CREATE TABLE ${schema}.schedule (
356
361
  name text REFERENCES ${schema}.queue ON DELETE CASCADE,
357
362
  key text not null DEFAULT '',
358
- kind text not null DEFAULT '${SCHEDULE_KINDS.cron}' CHECK (${SCHEDULE_KIND_CHECK}),
363
+ kind text not null DEFAULT '${SCHEDULE_KINDS.cron}' CHECK (kind IN ('${SCHEDULE_KINDS.cron}', '${SCHEDULE_KINDS.rrule}')),
359
364
  cron text not null,
360
365
  timezone text DEFAULT 'UTC',
361
366
  data jsonb,
@@ -530,9 +535,17 @@ function jobTableRunAsyncFunction(schema) {
530
535
  }
531
536
  /* eslint-disable no-restricted-syntax -- column defaults stay on the real clock: every pg-boss write names its timestamps through job_now() */
532
537
  function createTableJob(schema, noPartitioning = false) {
533
- // source_name / source_id / source_created_on / source_retry_count are dead-letter provenance:
534
- // where a job in a dead-letter queue came from, stamped at the transfer so the original queue,
535
- // id, enqueue time and retry count survive the move.
538
+ // source_name / source_id / source_created_on / source_retry_count / source_output are
539
+ // dead-letter provenance: where a job in a dead-letter queue came from, stamped at the transfer
540
+ // so the original queue, id, enqueue time, retry count and final output survive the move. The
541
+ // original's output is provenance rather than the copy's own `output`: the copy is a new job,
542
+ // and its `output` is whatever its own run produces.
543
+ //
544
+ // source_root_id is the one lineage column that is not about the last hop. source_id names the job
545
+ // that just failed, which after a redrive is the redriven copy, not the job send() returned; each
546
+ // round trip through a dead letter queue would add a hop, and retention deletes the failed rows the
547
+ // hops go through. The root is the id of the first job in the chain, copied onto every dead letter
548
+ // row and every redriven job after it, so one lookup finds them all however many trips it took.
536
549
  const partitionClause = noPartitioning ? '' : 'PARTITION BY LIST (name)';
537
550
  return `
538
551
  CREATE TABLE ${schema}.job (
@@ -568,17 +581,19 @@ function createTableJob(schema, noPartitioning = false) {
568
581
  source_name text,
569
582
  source_id uuid,
570
583
  source_created_on timestamp with time zone,
571
- source_retry_count int
584
+ source_retry_count int,
585
+ source_output jsonb,
586
+ source_root_id uuid
572
587
  ) ${partitionClause}
573
588
  `;
574
589
  }
575
- const JOB_COLUMNS_MIN = 'id, name, data, expire_seconds as "expireInSeconds", heartbeat_seconds as "heartbeatSeconds", group_id as "groupId", group_tier as "groupTier"';
590
+ // retry_count is in the minimal set because a worker settles against it.
591
+ const JOB_COLUMNS_MIN = 'id, name, data, retry_count as "retryCount", expire_seconds as "expireInSeconds", heartbeat_seconds as "heartbeatSeconds", group_id as "groupId", group_tier as "groupTier"';
576
592
  const JOB_COLUMNS_ALL = `${JOB_COLUMNS_MIN},
577
593
  policy,
578
594
  state,
579
595
  priority,
580
596
  retry_limit as "retryLimit",
581
- retry_count as "retryCount",
582
597
  retry_delay as "retryDelay",
583
598
  retry_backoff as "retryBackoff",
584
599
  retry_delay_max as "retryDelayMax",
@@ -599,7 +614,9 @@ const JOB_COLUMNS_ALL = `${JOB_COLUMNS_MIN},
599
614
  source_name as "sourceName",
600
615
  source_id as "sourceId",
601
616
  source_created_on as "sourceCreatedOn",
602
- source_retry_count as "sourceRetryCount"
617
+ source_retry_count as "sourceRetryCount",
618
+ source_output as "sourceOutput",
619
+ source_root_id as "sourceRootId"
603
620
  `;
604
621
  /* eslint-enable no-restricted-syntax */
605
622
  function createTableJobCommon(schema) {
@@ -620,6 +637,7 @@ function createTableJobCommon(schema) {
620
637
  SELECT ${schema}.job_table_run($cmd$${createIndexJobFetch(schema)}$cmd$, '${COMMON_JOB_TABLE}');
621
638
  SELECT ${schema}.job_table_run($cmd$${createIndexJobGroupConcurrency(schema)}$cmd$, '${COMMON_JOB_TABLE}');
622
639
  SELECT ${schema}.job_table_run($cmd$${createIndexJobBlocking(schema)}$cmd$, '${COMMON_JOB_TABLE}');
640
+ SELECT ${schema}.job_table_run($cmd$${createIndexJobSourceRoot(schema)}$cmd$, '${COMMON_JOB_TABLE}');
623
641
 
624
642
  ALTER TABLE ${schema}.job ATTACH PARTITION ${schema}.${COMMON_JOB_TABLE} DEFAULT;
625
643
  `;
@@ -640,6 +658,7 @@ function createTableJobIndexes(schema, noDeferrableConstraints = false, noCoveri
640
658
  ${createIndexJobFetch(schema, noCoveringIndex)};
641
659
  ${createIndexJobGroupConcurrency(schema)};
642
660
  ${createIndexJobBlocking(schema)};
661
+ ${createIndexJobSourceRoot(schema)};
643
662
  `;
644
663
  }
645
664
  function createQueueFunction(schema, noPartitioning = false) {
@@ -762,6 +781,7 @@ function createQueueFunction(schema, noPartitioning = false) {
762
781
  EXECUTE ${schema}.job_table_format($cmd$${createIndexJobThrottle(schema)}$cmd$, tablename);
763
782
  EXECUTE ${schema}.job_table_format($cmd$${createIndexJobGroupConcurrency(schema)}$cmd$, tablename);
764
783
  EXECUTE ${schema}.job_table_format($cmd$${createIndexJobBlocking(schema)}$cmd$, tablename);
784
+ EXECUTE ${schema}.job_table_format($cmd$${createIndexJobSourceRoot(schema)}$cmd$, tablename);
765
785
 
766
786
  IF options->>'policy' = 'short' THEN
767
787
  EXECUTE ${schema}.job_table_format($cmd$${createIndexJobPolicyShort(schema)}$cmd$, tablename);
@@ -932,53 +952,39 @@ function createIndexJobGroupConcurrency(schema) {
932
952
  function createIndexJobBlocking(schema) {
933
953
  return `CREATE INDEX job_i9 ON ${schema}.job (name, id) WHERE blocking AND state = '${JOB_STATES.completed}'`;
934
954
  }
935
- // The interval claim for a monitor pass, and the vacuum-safety gate on the expensive half of it.
936
- //
937
- // getQueueStats() is the only whole-job-table aggregate pg-boss runs, and while it is in flight it
938
- // advertises a snapshot that Postgres cannot vacuum past: a measured 200,000 deleted rows stayed
939
- // "dead but not yet removable" for exactly as long as one such snapshot was open, and were removed
940
- // by the very next vacuum after it closed. Run it densely enough and the job table can never be
941
- // reclaimed - the failure PlanetScale documents as a queue's death spiral, and the same one
942
- // #checkVacuum reports after the fact as `xmin_horizon`.
943
- //
944
- // The claim alone doesn't prevent that. It paces the aggregate start-to-start, so once the
945
- // aggregate itself runs longer than monitorIntervalSeconds the gate stops delaying anything and
946
- // passes run back to back with no gap at all. `monitor_backoff_on` is the second condition: a
947
- // deadline written by setMonitorBackoff() after a pass that was measurably slow, before which no
948
- // instance - timer-driven, manual supervise(), or a fresh CLI process - may start another
949
- // aggregate. Being a column rather than instance state is the point: the horizon is a property of
950
- // the database, so the gate has to be too.
951
- //
952
- // The backoff is returned as `refreshStats` rather than folded into the WHERE, and that distinction
953
- // is the whole reason this claim exists separately from the aggregate. A monitor pass is not only
954
- // the stats scan: it is also failJobsByTimeout and failJobsByHeartbeat, which are narrow indexed
955
- // updates that pin nothing and are the mechanism by which expired and heartbeat-dead jobs are
956
- // released. Suppressing the claim during a backoff would suspend those too, for two naptimes at
957
- // minimum and up to an hour at the cap - the vacuum-safety valve silently becoming a job-expiry
958
- // outage. So the pass is always claimed and always fails timed-out jobs; only the aggregate is
959
- // deferred.
955
+ // Finds every job in a dead letter chain from its root, for a lineage lookup. Keyed on the root
956
+ // alone, with no name: a chain crosses queues, from the source queue to its dead letter queue and
957
+ // back. Partial on the column being set, so only jobs that have
958
+ // been through a dead letter queue are in it and a queue that never dead-letters carries it empty.
959
+ function createIndexJobSourceRoot(schema) {
960
+ return `CREATE INDEX job_i12 ON ${schema}.job (source_root_id) WHERE source_root_id IS NOT NULL`;
961
+ }
962
+ // The interval claim for a monitor pass. It stamps monitor_claim_on, never monitor_on, which only
963
+ // the stats aggregate writes, so a pass that skips the aggregate leaves capturedOn aging.
960
964
  //
961
- // The claim stamps monitor_claim_on, never monitor_on. monitor_on belongs to the aggregate that
962
- // writes the counts (see cacheQueueStats), so a pass that claims and then skips the aggregate -
963
- // backed off, or beaten to the stats try-lock - leaves capturedOn correctly aging instead of
964
- // advertising a freshness the counts do not have.
965
+ // The stats aggregate pins the vacuum horizon, so after a slow one setMonitorBackoff() writes
966
+ // monitor_backoff_on and no instance starts another until it passes. The backoff comes back as
967
+ // refreshStats instead of blocking the claim: the pass still runs failJobsByTimeout and
968
+ // failJobsByHeartbeat, and only the aggregate waits.
965
969
  //
966
- // The NULL fallback reads monitor_on before it gives up and reports the queue eligible. A queue that
967
- // has never been claimed but has been monitored is one that crossed the v40 upgrade: v40 added
968
- // monitor_claim_on and seeds it from monitor_on so the first pass after a deploy does not make every
969
- // queue eligible at once, and that seed is the statement CockroachDB cannot run in the transaction
970
- // that added the column (see noAddColumnBackfill). Falling back to monitor_on here produces the
971
- // seeded answer without the seed, so the stampede is closed on every backend rather than only on the
972
- // ones whose migration could write the column. A genuinely new queue has neither stamp and stays
973
- // immediately eligible, which is what it should be.
974
- export function trySetQueueMonitorTime(schema, queues, seconds) {
970
+ // A queue never claimed falls back to monitor_on, so a queue that crossed the v40 upgrade without
971
+ // its seed (CockroachDB, see noAddColumnBackfill) is not due all at once. A new queue has neither
972
+ // stamp and is due immediately.
973
+ export function trySetQueueMonitorTime(schema, queues, seconds, skipLocked) {
975
974
  return {
976
975
  text: `
976
+ WITH due AS (
977
+ SELECT name
978
+ FROM ${schema}.queue
979
+ WHERE name = ANY($1::text[])
980
+ AND EXTRACT( EPOCH FROM (${schema}.job_now() - COALESCE(monitor_claim_on, monitor_on, ${schema}.job_now() - interval '1 week') ) ) >= ${seconds}
981
+ ${queueRowLock(skipLocked)}
982
+ )
977
983
  UPDATE ${schema}.queue
978
984
  SET monitor_claim_on = ${schema}.job_now()
979
- WHERE name = ANY($1::text[])
980
- AND EXTRACT( EPOCH FROM (${schema}.job_now() - COALESCE(monitor_claim_on, monitor_on, ${schema}.job_now() - interval '1 week') ) ) >= ${seconds}
981
- RETURNING name, NOT EXISTS (SELECT 1 FROM ${schema}.version WHERE monitor_backoff_on > ${schema}.job_now()) as "refreshStats"
985
+ FROM due
986
+ WHERE queue.name = due.name
987
+ RETURNING queue.name, NOT EXISTS (SELECT 1 FROM ${schema}.version WHERE monitor_backoff_on > ${schema}.job_now()) as "refreshStats"
982
988
  `,
983
989
  values: [queues]
984
990
  };
@@ -1051,8 +1057,8 @@ export function setMonitorBackoff(schema, elapsedSeconds) {
1051
1057
  values: [elapsedSeconds]
1052
1058
  };
1053
1059
  }
1054
- export function trySetQueueDeletionTime(schema, queues, seconds) {
1055
- return trySetQueueTimestamp(schema, queues, 'maintain_on', seconds);
1060
+ export function trySetQueueDeletionTime(schema, queues, seconds, skipLocked) {
1061
+ return trySetQueueTimestamp(schema, queues, 'maintain_on', seconds, skipLocked);
1056
1062
  }
1057
1063
  // The cron claim, which also answers with the timestamp it replaced and how old that timestamp was.
1058
1064
  // The timestamp is when an instance last ran a pass, so the pass reads it to find out how long
@@ -1110,18 +1116,42 @@ function trySetTimestamp(schema, column, seconds) {
1110
1116
  RETURNING true
1111
1117
  `;
1112
1118
  }
1113
- function trySetQueueTimestamp(schema, queues, column, seconds) {
1119
+ function trySetQueueTimestamp(schema, queues, column, seconds, skipLocked) {
1114
1120
  return {
1115
1121
  text: `
1122
+ WITH due AS (
1123
+ SELECT name
1124
+ FROM ${schema}.queue
1125
+ WHERE name = ANY($1::text[])
1126
+ AND EXTRACT( EPOCH FROM (${schema}.job_now() - COALESCE(${column}, ${schema}.job_now() - interval '1 week') ) ) >= ${seconds}
1127
+ ${queueRowLock(skipLocked)}
1128
+ )
1116
1129
  UPDATE ${schema}.queue
1117
1130
  SET ${column} = ${schema}.job_now()
1118
- WHERE name = ANY($1::text[])
1119
- AND EXTRACT( EPOCH FROM (${schema}.job_now() - COALESCE(${column}, ${schema}.job_now() - interval '1 week') ) ) >= ${seconds}
1120
- RETURNING name
1131
+ FROM due
1132
+ WHERE queue.name = due.name
1133
+ RETURNING queue.name
1121
1134
  `,
1122
1135
  values: [queues]
1123
1136
  };
1124
1137
  }
1138
+ // Every statement that writes more than one queue row locks them in name order first. A single
1139
+ // UPDATE locks rows in whatever order its plan visits them, and every write moves a row, so two
1140
+ // multi-row writers - two instances claiming the same queues, or a claim and cacheQueueStats - could
1141
+ // otherwise take the same rows in opposite orders and deadlock. With skipLocked a claim also skips
1142
+ // rows another session holds: a locked row is in another instance's pass, and the next interval
1143
+ // claims it again. Without it the claim waits, which the name order alone keeps deadlock-free.
1144
+ // NO KEY UPDATE is the lock a plain UPDATE of these columns takes; FOR UPDATE would also block the
1145
+ // KEY SHARE lock that inserting a job takes on its queue row through the foreign key.
1146
+ function queueRowLock(skipLocked) {
1147
+ return `ORDER BY name FOR NO KEY UPDATE${skipLocked ? ' SKIP LOCKED' : ''}`;
1148
+ }
1149
+ // Whether the queue claims may skip locked rows. Only on stock Postgres: the name order already
1150
+ // prevents the deadlock everywhere, and skipping is an optimization the other backends are not
1151
+ // tested with. PGlite has a single connection, so it has nothing to skip.
1152
+ export function queueClaimSkipLocked(backend, noSkipLocked) {
1153
+ return backend === 'postgres' && !noSkipLocked;
1154
+ }
1125
1155
  export function updateQueue(schema) {
1126
1156
  return `
1127
1157
  WITH options as (SELECT $2::jsonb as data)
@@ -1173,6 +1203,11 @@ export function getQueues(schema, names) {
1173
1203
  q.active_count as "activeCount",
1174
1204
  q.failed_count as "failedCount",
1175
1205
  q.total_count as "totalCount",
1206
+ q.created_delta as "createdDelta",
1207
+ q.completed_delta as "completedDelta",
1208
+ q.failed_delta as "failedDelta",
1209
+ q.delta_seconds as "deltaSeconds",
1210
+ q.delta_on as "deltaOn",
1176
1211
  q.singletons_active as "singletonsActive",
1177
1212
  q.table_name as "table",
1178
1213
  q.created_on as "createdOn",
@@ -1183,12 +1218,13 @@ export function getQueues(schema, names) {
1183
1218
  values: hasNames ? [names] : []
1184
1219
  };
1185
1220
  }
1186
- export function deleteJobsById(schema, table) {
1221
+ export function deleteJobsById(schema, table, fenced) {
1187
1222
  return `
1188
1223
  WITH results as (
1189
1224
  DELETE FROM ${schema}.${table}
1190
1225
  WHERE name = $1
1191
1226
  AND id = ANY($2::uuid[])
1227
+ ${fenced ? attemptFence(3) : ''}
1192
1228
  RETURNING id
1193
1229
  )
1194
1230
  ${settledCountAndIds()}
@@ -1353,6 +1389,10 @@ export function deleteOldWarnings(schema, days) {
1353
1389
  WHERE created_on < ${schema}.job_now() - interval '${days} days'
1354
1390
  `;
1355
1391
  }
1392
+ // The delta columns are nullable, unlike the gauges: a snapshot captured before they were counted
1393
+ // has no value, and zero would chart as an idle queue. Passes are not evenly spaced, so a rate is
1394
+ // sum(delta) / sum(delta_seconds), never delta / bucket width. delta_on trails captured_on by
1395
+ // DELTA_LAG, since the window a pass counts ends that far behind it.
1356
1396
  /* eslint-disable no-restricted-syntax -- column defaults stay on the real clock: every pg-boss write names its timestamps through job_now() */
1357
1397
  export function createTableQueueStats(schema, noPartitioning = false) {
1358
1398
  return `
@@ -1365,6 +1405,11 @@ export function createTableQueueStats(schema, noPartitioning = false) {
1365
1405
  active_count int NOT NULL DEFAULT 0,
1366
1406
  failed_count int NOT NULL DEFAULT 0,
1367
1407
  total_count int NOT NULL DEFAULT 0,
1408
+ created_delta int,
1409
+ completed_delta int,
1410
+ failed_delta int,
1411
+ delta_seconds int,
1412
+ delta_on timestamptz,
1368
1413
  captured_on timestamptz NOT NULL DEFAULT now(),
1369
1414
  ${noPartitioning ? 'PRIMARY KEY (id)' : 'PRIMARY KEY (id, captured_on)'}
1370
1415
  ) ${noPartitioning ? '' : 'PARTITION BY RANGE (captured_on)'}
@@ -1372,6 +1417,12 @@ export function createTableQueueStats(schema, noPartitioning = false) {
1372
1417
  }
1373
1418
  /* eslint-enable no-restricted-syntax */
1374
1419
  export function createIndexQueueStats(schema, noCoveringIndex = false) {
1420
+ // The three delta columns are deliberately *not* included. Adding them would
1421
+ // make a fresh install's index differ from a migrated one unless the migration
1422
+ // rebuilt it, and rebuilding the covering index on a partitioned table that is
1423
+ // large on exactly the installations that care about throughput is a heavy
1424
+ // price for making three int columns index-only. The lookup still uses this
1425
+ // index; it just visits the heap for those three values.
1375
1426
  const include = noCoveringIndex
1376
1427
  ? ''
1377
1428
  : 'INCLUDE (deferred_count, queued_count, ready_count, active_count, failed_count, total_count)';
@@ -1450,8 +1501,10 @@ export function deleteOldQueueStats(schema, days) {
1450
1501
  export function insertQueueStats(schema, queues, noAdvisoryLocks) {
1451
1502
  const sql = `
1452
1503
  INSERT INTO ${schema}.queue_stats
1453
- (name, deferred_count, queued_count, ready_count, active_count, failed_count, total_count, captured_on)
1454
- SELECT name, deferred_count, queued_count, ready_count, active_count, failed_count, total_count, ${schema}.job_now()
1504
+ (name, deferred_count, queued_count, ready_count, active_count, failed_count, total_count,
1505
+ created_delta, completed_delta, failed_delta, delta_seconds, delta_on, captured_on)
1506
+ SELECT name, deferred_count, queued_count, ready_count, active_count, failed_count, total_count,
1507
+ created_delta, completed_delta, failed_delta, delta_seconds, delta_on, ${schema}.job_now()
1455
1508
  FROM ${schema}.queue
1456
1509
  WHERE name = ANY(${serializeArrayParam(queues)})
1457
1510
  `;
@@ -1468,7 +1521,7 @@ export function insertQueueStats(schema, queues, noAdvisoryLocks) {
1468
1521
  // monitorBackoff rides along because the caller must serve this cache even when it is stale while
1469
1522
  // the vacuum-safety backoff is in force: a forced refresh runs the same whole-table aggregate the
1470
1523
  // backoff exists to space out, and a dashboard polling { force: true } would otherwise walk
1471
- // straight back into the pin the monitor just backed away from. See setMonitorBackoff.
1524
+ // straight back into the pin the monitor just backed away from.
1472
1525
  export function getQueueStatsCache(schema) {
1473
1526
  return `
1474
1527
  SELECT
@@ -1479,6 +1532,11 @@ export function getQueueStatsCache(schema) {
1479
1532
  active_count as "activeCount",
1480
1533
  failed_count as "failedCount",
1481
1534
  total_count as "totalCount",
1535
+ created_delta as "createdDelta",
1536
+ completed_delta as "completedDelta",
1537
+ failed_delta as "failedDelta",
1538
+ delta_seconds as "deltaSeconds",
1539
+ delta_on as "deltaOn",
1482
1540
  table_name as "table",
1483
1541
  monitor_on as "capturedOn",
1484
1542
  (extract(epoch from (${schema}.job_now() - monitor_on)) * 1000)::float8 as "cacheAgeMs",
@@ -1497,6 +1555,11 @@ export function getQueueStatsHistory(schema) {
1497
1555
  active_count as "activeCount",
1498
1556
  failed_count as "failedCount",
1499
1557
  total_count as "totalCount",
1558
+ created_delta as "createdDelta",
1559
+ completed_delta as "completedDelta",
1560
+ failed_delta as "failedDelta",
1561
+ delta_seconds as "deltaSeconds",
1562
+ delta_on as "deltaOn",
1500
1563
  captured_on as "capturedOn"
1501
1564
  FROM ${schema}.queue_stats
1502
1565
  WHERE name = $1
@@ -1523,6 +1586,29 @@ const STATS_AGG = {
1523
1586
  // mode 'auto', $5 is maxDataPoints; the width is derived so the series fits in $5 points.
1524
1587
  // from/to sets the range, but they cannot exceed the data's own min/max values.
1525
1588
  //
1589
+ // The three delta columns are summed rather than passed through `aggregate`, and
1590
+ // that is not an oversight. Every other column here is a gauge, where the
1591
+ // question a wider bucket asks is "how high did it get" or "what was it
1592
+ // typically"; these three are counters, where the only meaningful answer is "how
1593
+ // many in total". Averaging them would report a rate per capture interval
1594
+ // labelled as a count, which reads plausible and is wrong by whatever the
1595
+ // bucket width happens to be.
1596
+ //
1597
+ // They are also bucketed by a different time. A gauge belongs to the moment it was read,
1598
+ // captured_on. The counters in that same row describe an interval that ended DELTA_LAG earlier,
1599
+ // delta_on, so keyed on captured_on they would land a bucket or two late and sit beside the wrong
1600
+ // gauges. Each side is bucketed by its own time and the two are joined per bucket, which is why the
1601
+ // newest bucket carries null counters: the pass that counts its interval has not run yet, and a
1602
+ // later read fills it in.
1603
+ //
1604
+ // The join is not symmetric, though. A counter's bucket can hold no capture at all: passes drift,
1605
+ // so delta_on (a pass time minus the lag) lands a bucket away from the previous capture; a bucket
1606
+ // narrower than the monitor interval has more empty buckets than full ones. Emitting that bucket on its own
1607
+ // would chart the queue as empty, since a gauge with no capture reads as zero. So a counter bucket
1608
+ // with no gauges is folded into the newest gauge bucket at or before it (the capture at the end of
1609
+ // the counted interval reflects the state it left), or into the first gauge bucket in range when
1610
+ // none precedes it. Every bucket returned has a capture behind its gauges.
1611
+ //
1526
1612
  // The bucket key avoids date_bin() (PG14+): pg-boss supports PostgreSQL 13+ and CockroachDB/
1527
1613
  // YugabyteDB, none of which can rely on it. to_timestamp / extract(epoch) / floor exist on all of
1528
1614
  // them (extract returns double on PG13, numeric on PG14+; floor/division handle both identically),
@@ -1542,7 +1628,7 @@ export function getQueueStatsHistoryBucketed(schema, aggregate, mode) {
1542
1628
  FROM extent
1543
1629
  ),
1544
1630
  w AS (
1545
- SELECT greatest(1, ceil(extract(epoch from (hi - lo)) / greatest($5, 1))::bigint)::bigint AS secs
1631
+ SELECT greatest(1, ceil(extract(epoch from (hi - lo))::float8 / greatest($5, 1)::float8)::bigint)::bigint AS secs
1546
1632
  FROM bounds
1547
1633
  )`
1548
1634
  : 'WITH w AS (SELECT greatest($5, 1)::bigint AS secs)';
@@ -1551,20 +1637,75 @@ export function getQueueStatsHistoryBucketed(schema, aggregate, mode) {
1551
1637
  // maxDataPoints. ORDER BY DESC means the cap drops the oldest (straddle) bucket and keeps the
1552
1638
  // newest N. Explicit bucketSeconds has no target to overshoot, so it keeps the raw limit.
1553
1639
  const limit = mode === 'auto' ? 'least($4, $5)' : '$4';
1640
+ // float8 on both sides: CockroachDB has no float / int operator, and extract() is a float there.
1641
+ const bucket = (column) => `to_timestamp(floor(extract(epoch from ${column})::float8 / w.secs::float8) * w.secs::float8)`;
1554
1642
  return `
1555
- ${widthCte}
1643
+ ${widthCte},
1644
+ gauges AS (
1645
+ SELECT
1646
+ ${bucket('captured_on')} as bucket,
1647
+ ${agg('deferred_count')} as "deferredCount",
1648
+ ${agg('queued_count')} as "queuedCount",
1649
+ ${agg('ready_count')} as "readyCount",
1650
+ ${agg('active_count')} as "activeCount",
1651
+ ${agg('failed_count')} as "failedCount",
1652
+ ${agg('total_count')} as "totalCount"
1653
+ FROM ${schema}.queue_stats, w
1654
+ WHERE name = $1
1655
+ AND ($2::timestamptz IS NULL OR captured_on >= $2)
1656
+ AND ($3::timestamptz IS NULL OR captured_on <= $3)
1657
+ GROUP BY 1
1658
+ ),
1659
+ counters AS (
1660
+ SELECT
1661
+ ${bucket('delta_on')} as bucket,
1662
+ sum(created_delta)::int as "createdDelta",
1663
+ sum(completed_delta)::int as "completedDelta",
1664
+ sum(failed_delta)::int as "failedDelta",
1665
+ sum(delta_seconds)::int as "deltaSeconds",
1666
+ max(delta_on) as "deltaOn"
1667
+ FROM ${schema}.queue_stats, w
1668
+ WHERE name = $1
1669
+ AND delta_on IS NOT NULL
1670
+ AND ($2::timestamptz IS NULL OR delta_on >= $2)
1671
+ AND ($3::timestamptz IS NULL OR delta_on <= $3)
1672
+ GROUP BY 1
1673
+ ),
1674
+ placed AS (
1675
+ SELECT
1676
+ COALESCE(
1677
+ max(g.bucket) OVER (ORDER BY COALESCE(g.bucket, c.bucket) ROWS UNBOUNDED PRECEDING),
1678
+ min(g.bucket) OVER ()
1679
+ ) as bucket,
1680
+ g."deferredCount",
1681
+ g."queuedCount",
1682
+ g."readyCount",
1683
+ g."activeCount",
1684
+ g."failedCount",
1685
+ g."totalCount",
1686
+ c."createdDelta",
1687
+ c."completedDelta",
1688
+ c."failedDelta",
1689
+ c."deltaSeconds",
1690
+ c."deltaOn"
1691
+ FROM gauges g
1692
+ FULL JOIN counters c ON c.bucket = g.bucket
1693
+ )
1556
1694
  SELECT
1557
- to_timestamp(floor(extract(epoch from captured_on) / w.secs) * w.secs) as "capturedOn",
1558
- ${agg('deferred_count')} as "deferredCount",
1559
- ${agg('queued_count')} as "queuedCount",
1560
- ${agg('ready_count')} as "readyCount",
1561
- ${agg('active_count')} as "activeCount",
1562
- ${agg('failed_count')} as "failedCount",
1563
- ${agg('total_count')} as "totalCount"
1564
- FROM ${schema}.queue_stats, w
1565
- WHERE name = $1
1566
- AND ($2::timestamptz IS NULL OR captured_on >= $2)
1567
- AND ($3::timestamptz IS NULL OR captured_on <= $3)
1695
+ bucket as "capturedOn",
1696
+ max("deferredCount")::int as "deferredCount",
1697
+ max("queuedCount")::int as "queuedCount",
1698
+ max("readyCount")::int as "readyCount",
1699
+ max("activeCount")::int as "activeCount",
1700
+ max("failedCount")::int as "failedCount",
1701
+ max("totalCount")::int as "totalCount",
1702
+ sum("createdDelta")::int as "createdDelta",
1703
+ sum("completedDelta")::int as "completedDelta",
1704
+ sum("failedDelta")::int as "failedDelta",
1705
+ sum("deltaSeconds")::int as "deltaSeconds",
1706
+ max("deltaOn") as "deltaOn"
1707
+ FROM placed
1708
+ WHERE bucket IS NOT NULL
1568
1709
  GROUP BY 1
1569
1710
  ORDER BY 1 DESC
1570
1711
  LIMIT ${limit}
@@ -1841,7 +1982,7 @@ export function fetchNextJob(options, noSkipLocked = false) {
1841
1982
  const updateMatch = hasGroupConcurrency
1842
1983
  ? `j.id = ANY (ARRAY(SELECT id FROM ${finalCte}))`
1843
1984
  : `j.id = ${finalCte}.id`;
1844
- // Without SKIP LOCKED, add a state check to prevent duplicate processing
1985
+ // Without SKIP LOCKED, add a state check to prevent duplicate claims
1845
1986
  // when multiple workers try to claim the same jobs concurrently
1846
1987
  const distributedStateCheck = noSkipLocked ? `AND j.state < '${JOB_STATES.active}'` : '';
1847
1988
  return {
@@ -1866,9 +2007,56 @@ export function fetchNextJob(options, noSkipLocked = false) {
1866
2007
  values: params.values
1867
2008
  };
1868
2009
  }
2010
+ // The attempt fence (#925). A worker whose claim lapsed (a heartbeat the database stopped seeing, an
2011
+ // expiry) keeps running its handler, and by the time it settles, the job can be `active` again under
2012
+ // another worker's claim. Matching on id and state alone lets the stale settle land on that newer
2013
+ // attempt: its complete() overwrites the newer attempt's outcome, and its fail() deletes the live
2014
+ // attempt and re-queues it. Every claim increments retry_count (fetchNextJob), so the value a worker
2015
+ // fetched names its attempt. `param` is a text[] of 'id:retry_count' pairs (attemptPairs), and a
2016
+ // job whose retry_count has moved on is left alone. Always paired with state = 'active': a job failed
2017
+ // back to `retry` still carries the stale attempt's retry_count until it is claimed again.
2018
+ //
2019
+ // The pair is matched with = ANY because Postgres hashes that lookup against a parameter array, so a
2020
+ // settle costs the same fenced as unfenced. Measured at 5000 jobs: 58ms against 54ms unfenced. Looking
2021
+ // each id's position up with array_position scans the array once per row (149ms), and joining an
2022
+ // unnest() of the two arrays is planned as a nested loop over every pair (1063ms).
2023
+ //
2024
+ // restore() clears started_on, so the claim after it does not increment. The fence cannot tell a
2025
+ // restored job's next claim from the one before it. pg-boss itself never restores a job whose handler
2026
+ // is still running (the localGroupConcurrency excess is restored before its handler starts), but a
2027
+ // caller of the public restore() can.
2028
+ function attemptFence(param) {
2029
+ return `AND state = '${JOB_STATES.active}'
2030
+ AND (id::text || ':' || retry_count::text) = ANY($${param}::text[])`;
2031
+ }
2032
+ // The attemptFence parameter for jobs `ids` fetched at `attempts` (parallel arrays).
2033
+ export function attemptPairs(ids, attempts) {
2034
+ return ids.map((id, i) => `${canonicalUuid(id)}:${attempts[i]}`);
2035
+ }
2036
+ const CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
2037
+ // The fence compares id::text, which Postgres and CockroachDB always render lower case and hyphenated,
2038
+ // while the id lookup beside it takes a uuid parameter and so accepts any spelling the uuid type does
2039
+ // (upper case, braces, missing hyphens). A caller building { id, retryCount } from an id stored in
2040
+ // another spelling would pass the lookup and silently miss the fence, so the pair is built from the
2041
+ // canonical form. Done here rather than in SQL, so the fence stays a hashed = ANY over a parameter.
2042
+ // Ids from fetch() and work() are already canonical and take the regex test alone. Anything that is
2043
+ // not 32 hex digits is left as it is: it is not a uuid, and the lookup rejects it.
2044
+ function canonicalUuid(id) {
2045
+ if (CANONICAL_UUID.test(id))
2046
+ return id;
2047
+ const hex = id.replace(/[{}-]/g, '').toLowerCase();
2048
+ if (!/^[0-9a-f]{32}$/.test(hex))
2049
+ return id;
2050
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
2051
+ }
2052
+ // The fence for the per-job-output statements, which carry each job's retry_count in the recordset.
2053
+ function recordsetAttemptFence(alias, input) {
2054
+ return `AND ${alias}state = '${JOB_STATES.active}'
2055
+ AND ${alias}retry_count = ${input}.retry_count`;
2056
+ }
1869
2057
  // Shared SET/WHERE body for marking jobs completed (no RETURNING). Used by the
1870
2058
  // single-statement completeJobs() and the distributed completeJobsDistributed().
1871
- function completeJobsUpdate(schema, table, includeQueued) {
2059
+ function completeJobsUpdate(schema, table, includeQueued, fenced) {
1872
2060
  return `UPDATE ${schema}.${table}
1873
2061
  SET completed_on = ${schema}.job_now(),
1874
2062
  state = '${JOB_STATES.completed}',
@@ -1879,7 +2067,8 @@ function completeJobsUpdate(schema, table, includeQueued) {
1879
2067
  AND id = ANY($2::uuid[])
1880
2068
  AND ${includeQueued
1881
2069
  ? `state < '${JOB_STATES.completed}'`
1882
- : `state = '${JOB_STATES.active}'`}`;
2070
+ : `state = '${JOB_STATES.active}'`}
2071
+ ${fenced ? attemptFence(4) : ''}`;
1883
2072
  }
1884
2073
  // Shared dependency-unblocking fragments. Both consume a `decremented` CTE
1885
2074
  // (child_name, child_id, n) that the caller defines, and are reused by the standard
@@ -1907,10 +2096,10 @@ function unblockChildrenUpdate(schema) {
1907
2096
  // dependents inline (joining job_dependency and the partitioned job table) made completion
1908
2097
  // scale with partition count (see issue #824). The background resolver (Navigator) handles
1909
2098
  // unblocking out of band, driven by the job_i9 partial index.
1910
- export function completeJobs(schema, table, includeQueued) {
2099
+ export function completeJobs(schema, table, includeQueued, fenced) {
1911
2100
  return `
1912
2101
  WITH results AS (
1913
- ${completeJobsUpdate(schema, table, includeQueued)}
2102
+ ${completeJobsUpdate(schema, table, includeQueued, fenced)}
1914
2103
  RETURNING id
1915
2104
  )
1916
2105
  ${settledCountAndIds()}
@@ -1922,7 +2111,7 @@ export function completeJobs(schema, table, includeQueued) {
1922
2111
  export function completeJobsWithOutputs(schema, table) {
1923
2112
  return `
1924
2113
  WITH input AS (
1925
- SELECT * FROM json_to_recordset($2::text::json) AS x (id uuid, output jsonb)
2114
+ SELECT * FROM json_to_recordset($2::text::json) AS x (id uuid, retry_count int, output jsonb)
1926
2115
  ),
1927
2116
  results AS (
1928
2117
  UPDATE ${schema}.${table} j
@@ -1932,10 +2121,10 @@ export function completeJobsWithOutputs(schema, table) {
1932
2121
  FROM input i
1933
2122
  WHERE j.name = $1
1934
2123
  AND j.id = i.id
1935
- AND j.state = '${JOB_STATES.active}'
1936
- RETURNING 1
2124
+ ${recordsetAttemptFence('j.', 'i')}
2125
+ RETURNING j.id
1937
2126
  )
1938
- SELECT COUNT(*) FROM results
2127
+ ${settledCountAndIds()}
1939
2128
  `;
1940
2129
  }
1941
2130
  // Distributed equivalent of completeJobsWithOutputs: a single mutation that returns the completed
@@ -1944,7 +2133,7 @@ export function completeJobsWithOutputs(schema, table) {
1944
2133
  export function completeJobsWithOutputsDistributed(schema, table) {
1945
2134
  return `
1946
2135
  WITH input AS (
1947
- SELECT * FROM json_to_recordset($2::text::json) AS x (id uuid, output jsonb)
2136
+ SELECT * FROM json_to_recordset($2::text::json) AS x (id uuid, retry_count int, output jsonb)
1948
2137
  )
1949
2138
  UPDATE ${schema}.${table} j
1950
2139
  SET completed_on = ${schema}.job_now(),
@@ -1953,11 +2142,11 @@ export function completeJobsWithOutputsDistributed(schema, table) {
1953
2142
  FROM input i
1954
2143
  WHERE j.name = $1
1955
2144
  AND j.id = i.id
1956
- AND j.state = '${JOB_STATES.active}'
2145
+ ${recordsetAttemptFence('j.', 'i')}
1957
2146
  RETURNING j.id
1958
2147
  `;
1959
2148
  }
1960
- export function cancelJobs(schema, table) {
2149
+ export function cancelJobs(schema, table, fenced) {
1961
2150
  return `
1962
2151
  WITH results as (
1963
2152
  UPDATE ${schema}.${table}
@@ -1966,6 +2155,7 @@ export function cancelJobs(schema, table) {
1966
2155
  WHERE name = $1
1967
2156
  AND id = ANY($2::uuid[])
1968
2157
  AND state < '${JOB_STATES.completed}'
2158
+ ${fenced ? attemptFence(3) : ''}
1969
2159
  RETURNING id
1970
2160
  )
1971
2161
  ${settledCountAndIds()}
@@ -2151,8 +2341,8 @@ export function insertFlowJobs(schema, { table, name }, jobs) {
2151
2341
  SELECT 1 / (CASE WHEN (SELECT count(*) FROM ins) = ${jobs.length} THEN 1 ELSE 0 END)
2152
2342
  `;
2153
2343
  }
2154
- export function failJobsById(schema, table) {
2155
- const where = `name = $1 AND id = ANY($2::uuid[]) AND state < '${JOB_STATES.completed}'`;
2344
+ export function failJobsById(schema, table, fenced) {
2345
+ const where = `name = $1 AND id = ANY($2::uuid[]) AND state < '${JOB_STATES.completed}' ${fenced ? attemptFence(4) : ''}`;
2156
2346
  const output = '$3::jsonb';
2157
2347
  return failJobs(schema, table, where, output, true);
2158
2348
  }
@@ -2171,7 +2361,7 @@ export function failJobsByHeartbeat(schema, table, queues, noAdvisoryLocks) {
2171
2361
  const output = '\'{ "value": { "message": "job heartbeat timeout" } }\'::jsonb';
2172
2362
  return locked(schema, failJobs(schema, table, where, output), table + 'failJobsByHeartbeat', noAdvisoryLocks);
2173
2363
  }
2174
- export function touchJobs(schema, table) {
2364
+ export function touchJobs(schema, table, fenced) {
2175
2365
  return `
2176
2366
  WITH results AS (
2177
2367
  UPDATE ${schema}.${table}
@@ -2179,9 +2369,10 @@ export function touchJobs(schema, table) {
2179
2369
  WHERE name = $1
2180
2370
  AND id = ANY($2::uuid[])
2181
2371
  AND state = '${JOB_STATES.active}'
2182
- RETURNING 1
2372
+ ${fenced ? attemptFence(3) : ''}
2373
+ RETURNING id
2183
2374
  )
2184
- SELECT COUNT(*) FROM results
2375
+ ${settledCountAndIds()}
2185
2376
  `;
2186
2377
  }
2187
2378
  // `returnIds` is off for the supervisor's bulk maintenance (failJobsByTimeout/failJobsByHeartbeat),
@@ -2210,6 +2401,9 @@ function settledCountAndIds() {
2210
2401
  // Both re-inserts carry the source_* provenance columns. A job in a dead letter queue that its
2211
2402
  // own worker fails is deleted and re-inserted here like any other, and without them it would
2212
2403
  // forget which queue it came from and become unroutable for redrive.
2404
+ //
2405
+ // The dead letter copy takes the failed job's output as source_output, not as its own output. The
2406
+ // copy is a new job that has not run yet, so its output starts empty like any other.
2213
2407
  function failJobsBody(schema, table, where, output, forceTerminal = false) {
2214
2408
  const state = forceTerminal
2215
2409
  ? `'${JOB_STATES.failed}'::${schema}.job_state`
@@ -2259,7 +2453,9 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
2259
2453
  source_name,
2260
2454
  source_id,
2261
2455
  source_created_on,
2262
- source_retry_count
2456
+ source_retry_count,
2457
+ source_output,
2458
+ source_root_id
2263
2459
  )
2264
2460
  SELECT
2265
2461
  id,
@@ -2303,7 +2499,9 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
2303
2499
  source_name,
2304
2500
  source_id,
2305
2501
  source_created_on,
2306
- source_retry_count
2502
+ source_retry_count,
2503
+ source_output,
2504
+ source_root_id
2307
2505
  FROM deleted_jobs
2308
2506
  ON CONFLICT DO NOTHING
2309
2507
  RETURNING *
@@ -2342,7 +2540,9 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
2342
2540
  source_name,
2343
2541
  source_id,
2344
2542
  source_created_on,
2345
- source_retry_count
2543
+ source_retry_count,
2544
+ source_output,
2545
+ source_root_id
2346
2546
  )
2347
2547
  SELECT
2348
2548
  id,
@@ -2377,7 +2577,9 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
2377
2577
  source_name,
2378
2578
  source_id,
2379
2579
  source_created_on,
2380
- source_retry_count
2580
+ source_retry_count,
2581
+ source_output,
2582
+ source_root_id
2381
2583
  FROM deleted_jobs
2382
2584
  WHERE id NOT IN (SELECT id from retried_jobs)
2383
2585
  RETURNING *
@@ -2388,13 +2590,13 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
2388
2590
  SELECT * FROM failed_jobs
2389
2591
  ),
2390
2592
  dlq_jobs as (
2391
- INSERT INTO ${schema}.job (name, priority, data, output, retry_limit, retry_backoff, retry_delay, start_after, created_on, keep_until, deletion_seconds,
2392
- expire_seconds, source_name, source_id, source_created_on, source_retry_count, singleton_key, group_id, group_tier, heartbeat_seconds)
2593
+ INSERT INTO ${schema}.job (name, priority, data, retry_limit, retry_backoff, retry_delay, start_after, created_on, keep_until, deletion_seconds,
2594
+ expire_seconds, singleton_key, group_id, group_tier, heartbeat_seconds,
2595
+ source_name, source_id, source_created_on, source_retry_count, source_output, source_root_id)
2393
2596
  SELECT
2394
2597
  r.dead_letter,
2395
2598
  r.priority,
2396
2599
  r.data,
2397
- r.output,
2398
2600
  q.retry_limit,
2399
2601
  q.retry_backoff,
2400
2602
  q.retry_delay,
@@ -2403,14 +2605,16 @@ function failJobsBody(schema, table, where, output, forceTerminal = false) {
2403
2605
  ${schema}.job_now() + q.retention_seconds * interval '1s',
2404
2606
  q.deletion_seconds,
2405
2607
  q.expire_seconds,
2608
+ r.singleton_key,
2609
+ r.group_id,
2610
+ r.group_tier,
2611
+ q.heartbeat_seconds,
2406
2612
  r.name,
2407
2613
  r.id,
2408
2614
  r.created_on,
2409
2615
  r.retry_count,
2410
- r.singleton_key,
2411
- r.group_id,
2412
- r.group_tier,
2413
- q.heartbeat_seconds
2616
+ r.output,
2617
+ COALESCE(r.source_root_id, r.id)
2414
2618
  FROM results r
2415
2619
  JOIN ${schema}.queue q ON q.name = r.dead_letter
2416
2620
  WHERE state = '${JOB_STATES.failed}'
@@ -2420,27 +2624,27 @@ export function failJobsByIdWithOutputs(schema, table) {
2420
2624
  // Output is supplied per job via a JSON recordset ($2). `where` and the output expression both
2421
2625
  // reference the output_map CTE so each re-inserted job keeps its own output. Constant number of
2422
2626
  // statements regardless of batch size.
2423
- const where = `name = $1 AND id IN (SELECT id FROM output_map) AND state < '${JOB_STATES.completed}'`;
2627
+ const where = `name = $1 AND (id, retry_count) IN (SELECT id, retry_count FROM output_map) AND state = '${JOB_STATES.active}'`;
2424
2628
  const output = '(SELECT om.output FROM output_map om WHERE om.id = deleted_jobs.id)';
2425
2629
  return `
2426
2630
  WITH output_map AS (
2427
- SELECT * FROM json_to_recordset($2::text::json) AS x (id uuid, output jsonb)
2631
+ SELECT * FROM json_to_recordset($2::text::json) AS x (id uuid, retry_count int, output jsonb)
2428
2632
  ),
2429
2633
  ${failJobsBody(schema, table, where, output)}
2430
- SELECT COUNT(*) FROM results
2634
+ ${settledCountAndIds()}
2431
2635
  `;
2432
2636
  }
2433
2637
  // Like failJobsByIdWithOutputs, but fails every job terminally (forceTerminal) so it routes straight
2434
2638
  // to the dead letter queue, bypassing remaining retries. Backs the perJobResults `deadletter` status.
2435
2639
  export function deadLetterJobsByIdWithOutputs(schema, table) {
2436
- const where = `name = $1 AND id IN (SELECT id FROM output_map) AND state < '${JOB_STATES.completed}'`;
2640
+ const where = `name = $1 AND (id, retry_count) IN (SELECT id, retry_count FROM output_map) AND state = '${JOB_STATES.active}'`;
2437
2641
  const output = '(SELECT om.output FROM output_map om WHERE om.id = deleted_jobs.id)';
2438
2642
  return `
2439
2643
  WITH output_map AS (
2440
- SELECT * FROM json_to_recordset($2::text::json) AS x (id uuid, output jsonb)
2644
+ SELECT * FROM json_to_recordset($2::text::json) AS x (id uuid, retry_count int, output jsonb)
2441
2645
  ),
2442
2646
  ${failJobsBody(schema, table, where, output, true)}
2443
- SELECT COUNT(*) FROM results
2647
+ ${settledCountAndIds()}
2444
2648
  `;
2445
2649
  }
2446
2650
  // Distributed mode: separate queries to avoid CockroachDB's multi-mutation CTE limitation
@@ -2457,10 +2661,10 @@ const REBOUND_TIMESTAMPS_AS_TEXT = `started_on::text as started_on_text,
2457
2661
  keep_until::text as keep_until_text,
2458
2662
  start_after::text as start_after_text,
2459
2663
  source_created_on::text as source_created_on_text`;
2460
- export function selectJobsToFailById(schema, table) {
2664
+ export function selectJobsToFailById(schema, table, fenced) {
2461
2665
  return {
2462
2666
  text: `SELECT *, ${REBOUND_TIMESTAMPS_AS_TEXT}
2463
- FROM ${schema}.${table} WHERE name = $1 AND id = ANY($2::uuid[]) AND state < '${JOB_STATES.completed}'`,
2667
+ FROM ${schema}.${table} WHERE name = $1 AND id = ANY($2::uuid[]) AND state < '${JOB_STATES.completed}' ${fenced ? attemptFence(3) : ''}`,
2464
2668
  values: []
2465
2669
  };
2466
2670
  }
@@ -2503,9 +2707,9 @@ export function deleteJobsByIds(schema, table) {
2503
2707
  }
2504
2708
  // Distributed mode: complete jobs as a single-table mutation. Dependency unblocking is handled
2505
2709
  // out of band by the background resolver (Navigator), so completion does no dependency work.
2506
- export function completeJobsDistributed(schema, table, includeQueued) {
2710
+ export function completeJobsDistributed(schema, table, includeQueued, fenced) {
2507
2711
  return `
2508
- ${completeJobsUpdate(schema, table, includeQueued)}
2712
+ ${completeJobsUpdate(schema, table, includeQueued, fenced)}
2509
2713
  RETURNING id
2510
2714
  `;
2511
2715
  }
@@ -2611,21 +2815,22 @@ export function insertRetryJob(schema, table) {
2611
2815
  group_id, group_tier, expire_seconds, deletion_seconds, created_on, completed_on,
2612
2816
  keep_until, policy, output, dead_letter,
2613
2817
  heartbeat_on, heartbeat_seconds, blocked, blocking, pending_dependencies,
2614
- source_name, source_id, source_created_on, source_retry_count
2818
+ source_name, source_id, source_created_on, source_retry_count, source_output, source_root_id
2615
2819
  ) VALUES (
2616
2820
  $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24,
2617
- $25, $26, $27, $28, $29, $30, $31, $32, $33
2821
+ $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35
2618
2822
  ) ON CONFLICT DO NOTHING
2619
2823
  RETURNING id
2620
2824
  `;
2621
2825
  }
2622
2826
  export function insertDeadLetterJob(schema) {
2623
2827
  return `
2624
- INSERT INTO ${schema}.job (name, data, output, retry_limit, retry_backoff, retry_delay, start_after, created_on, keep_until, deletion_seconds,
2625
- expire_seconds, source_name, source_id, source_created_on, source_retry_count, singleton_key, heartbeat_seconds,
2626
- priority, group_id, group_tier)
2627
- SELECT $1, $2, $3, q.retry_limit, q.retry_backoff, q.retry_delay, ${schema}.job_now(), ${schema}.job_now(), ${schema}.job_now() + q.retention_seconds * interval '1s', q.deletion_seconds,
2628
- q.expire_seconds, $4, $5, $6, $7, $8, q.heartbeat_seconds, $9, $10, $11
2828
+ INSERT INTO ${schema}.job (name, data, priority, retry_limit, retry_backoff, retry_delay, start_after, created_on, keep_until, deletion_seconds,
2829
+ expire_seconds, singleton_key, group_id, group_tier, heartbeat_seconds,
2830
+ source_name, source_id, source_created_on, source_retry_count, source_output, source_root_id)
2831
+ SELECT $1, $2, $9, q.retry_limit, q.retry_backoff, q.retry_delay, ${schema}.job_now(), ${schema}.job_now(), ${schema}.job_now() + q.retention_seconds * interval '1s', q.deletion_seconds,
2832
+ q.expire_seconds, $8, $10, $11, q.heartbeat_seconds,
2833
+ $4, $5, $6, $7, $3, COALESCE($12::uuid, $5::uuid)
2629
2834
  FROM ${schema}.queue q WHERE q.name = $1
2630
2835
  `;
2631
2836
  }
@@ -2634,100 +2839,180 @@ export function insertDeadLetterJob(schema) {
2634
2839
  // $2 destination override, $3 sourceName, $4 data (jsonb containment), $5 createdBefore,
2635
2840
  // $6 ids. Each filter is off when its parameter is null. Only jobs not yet active are
2636
2841
  // candidates: a job the dead letter queue's own workers failed stays where it is.
2637
- function redriveWhere() {
2842
+ //
2843
+ // A key_strict_fifo job whose key is held by an active, retrying or failed job waits behind it, as
2844
+ // it would for fetch. job_i8 allows one holder per key, so a collision could not fail it in place,
2845
+ // and left queued it would be a candidate on every call and a draining loop would never end.
2846
+ function redriveWhere(schema, table) {
2638
2847
  return `j.name = $1
2639
2848
  AND j.state < '${JOB_STATES.active}'
2849
+ AND NOT EXISTS (
2850
+ SELECT 1 FROM ${schema}.${table} k
2851
+ WHERE j.policy = '${QUEUE_POLICIES.key_strict_fifo}'
2852
+ AND k.name = j.name
2853
+ AND k.singleton_key = j.singleton_key
2854
+ AND k.policy = '${QUEUE_POLICIES.key_strict_fifo}'
2855
+ AND k.state IN ('${JOB_STATES.active}', '${JOB_STATES.retry}', '${JOB_STATES.failed}')
2856
+ )
2640
2857
  AND ($3::text IS NULL OR j.source_name = $3)
2641
2858
  AND ($4::jsonb IS NULL OR j.data @> $4::jsonb)
2642
2859
  AND ($5::timestamptz IS NULL OR j.created_on < $5)
2643
2860
  AND ($6::uuid[] IS NULL OR j.id = ANY($6::uuid[]))`;
2644
2861
  }
2645
- // Dead-letter redrive. Moves un-started jobs out of a dead-letter queue and
2646
- // re-creates them as fresh jobs on their original source queue (or $2 destination override),
2647
- // oldest-first, capped at $7 and narrowed by the filters in redriveWhere. The JOIN in
2648
- // `candidates` only matches jobs whose destination queue exists, so legacy/orphaned jobs
2649
- // (NULL source_name, no override) are never deleted. They stay
2650
- // in the DLQ rather than being lost. Re-created jobs get a new id, `created` state, retry_count 0,
2651
- // cleared output, NULL source_*, and every queue-config column (retry/retention/policy/expiry/
2862
+ // The columns a redriven job is created with, shared by both redrive paths. `m` is the dead letter
2863
+ // row and `q` the destination queue. Re-created jobs get a new id, `created` state, retry_count 0,
2864
+ // cleared output, NULL source_* except source_root_id, which carries the chain's first job on (see
2865
+ // createTableJob), and every queue-config column (retry/retention/policy/expiry/
2652
2866
  // heartbeat/dead_letter) from the destination queue as it is configured now, per-job overrides
2653
2867
  // from the original send() are not preserved, since the DLQ copy never stored them. `dead_letter`
2654
2868
  // is the same value send() falls back to, so a second terminal failure re-enters the DLQ.
2655
2869
  // Job-identity columns (priority, singleton_key, group_id, group_tier) are carried over instead.
2870
+ const REDRIVE_INSERT_COLUMNS = `(id, name, data, priority, retry_limit, retry_backoff, retry_delay, retry_delay_max,
2871
+ expire_seconds, start_after, created_on, keep_until, deletion_seconds, policy, singleton_key, group_id, group_tier,
2872
+ heartbeat_seconds, dead_letter, source_root_id)`;
2873
+ function redriveInsertValues(schema, newId, destination) {
2874
+ return `${newId}, COALESCE(${destination}, m.source_name), m.data, m.priority, q.retry_limit, q.retry_backoff,
2875
+ q.retry_delay, q.retry_delay_max, q.expire_seconds, ${schema}.job_now(), ${schema}.job_now(),
2876
+ ${schema}.job_now() + q.retention_seconds * interval '1s', q.deletion_seconds, q.policy,
2877
+ m.singleton_key, m.group_id, m.group_tier, q.heartbeat_seconds, q.dead_letter,
2878
+ COALESCE(m.source_root_id, m.source_id)`;
2879
+ }
2880
+ // What a job that could not be re-created becomes: failed, in place, in the dead letter queue, with
2881
+ // the reason as its output. Never deleted: before 12.35 it was, and the job was simply gone. As a
2882
+ // failed job it is removed by the dead letter queue's own deleteAfterSeconds like any other, so
2883
+ // nothing accumulates, and until then it can be seen and retried. Failed rather than left waiting because a
2884
+ // waiting job is a candidate again on the next call, and redrive takes candidates oldest first, so
2885
+ // a handful of them would fill every later batch and stall the drain. Failed is also the state
2886
+ // redrive already leaves alone, and retrying the job makes it a candidate again once whatever it
2887
+ // collided with has finished.
2888
+ //
2889
+ // The output it had is kept as source_output when that is empty, which only happens on a job
2890
+ // dead-lettered before source_output existed: those copied the original's output into their own.
2891
+ // `j` is the dead letter row and `q` the destination queue.
2656
2892
  //
2657
- // The insert's ON CONFLICT DO NOTHING is load-bearing: a destination queue's short/stately policy
2658
- // can still collide on (name, singleton_key) if two redriven jobs share a key (job_i1/job_i3), and
2659
- // dropping just that row (matching retried_jobs' ON CONFLICT DO NOTHING elsewhere) is preferable
2660
- // to aborting the whole batch. The dropped job has already been deleted from the DLQ by the moved
2661
- // CTE and is not restored.
2893
+ // Failing two key_strict_fifo jobs with the same key would violate job_i8, so of those only the
2894
+ // oldest is failed. The rest stay queued behind it, and redriveWhere skips them until it is resolved.
2895
+ // `conflicts` is a predicate on `m.id` selecting the dead letter rows that were not re-created.
2896
+ function redriveConflictFailable(schema, table, conflicts) {
2897
+ return `(j.policy IS DISTINCT FROM '${QUEUE_POLICIES.key_strict_fifo}' OR j.singleton_key IS NULL OR j.id IN (
2898
+ SELECT DISTINCT ON (m.singleton_key) m.id
2899
+ FROM ${schema}.${table} m
2900
+ WHERE ${conflicts} AND m.policy = '${QUEUE_POLICIES.key_strict_fifo}'
2901
+ ORDER BY m.singleton_key, m.created_on, m.id
2902
+ ))`;
2903
+ }
2904
+ function redriveConflictSet(schema) {
2905
+ return `state = '${JOB_STATES.failed}',
2906
+ completed_on = ${schema}.job_now(),
2907
+ source_output = COALESCE(j.source_output, j.output),
2908
+ output = jsonb_build_object(
2909
+ 'message', 'Not redriven: queue ' || q.name || CASE
2910
+ WHEN j.singleton_key IS NULL THEN ' already has a job this one conflicts with'
2911
+ ELSE ' already has a job with singletonKey ' || j.singleton_key
2912
+ END || ' under its ' || COALESCE(q.policy, 'standard') || ' policy',
2913
+ 'reason', 'redrive_conflict',
2914
+ 'destination', q.name,
2915
+ 'policy', q.policy,
2916
+ 'singletonKey', j.singleton_key
2917
+ )`;
2918
+ }
2919
+ // Dead-letter redrive. Moves un-started jobs out of a dead-letter queue and
2920
+ // re-creates them as fresh jobs on their original source queue (or $2 destination override),
2921
+ // oldest-first, capped at $7 and narrowed by the filters in redriveWhere. The JOIN in
2922
+ // `candidates` only matches jobs whose destination queue exists, so legacy/orphaned jobs
2923
+ // (NULL source_name, no override) are never deleted. They stay in the DLQ rather than being lost.
2924
+ //
2925
+ // The insert's ON CONFLICT DO NOTHING is load-bearing: a destination queue's short/stately/exclusive
2926
+ // policy can collide on (name, singleton_key), with a job already there or with another job in the
2927
+ // same batch, and skipping just that row is preferable to aborting the whole batch. The dead letter
2928
+ // row of a job that was not re-created is failed in place with the reason (redriveConflictSet).
2929
+ //
2930
+ // Every candidate is given its new id up front, so `ins` RETURNING says exactly which ones were
2931
+ // re-created: a data-modifying CTE cannot see the others' writes, so there is no other way to tell
2932
+ // within one statement. `candidates` is referenced more than once and is FOR UPDATE, so it is
2933
+ // materialized, and each row keeps the one id it was given.
2662
2934
  export function redriveJobs(schema, table) {
2663
2935
  return `
2664
2936
  WITH candidates AS (
2665
- SELECT j.id
2937
+ SELECT j.id, gen_random_uuid() AS new_id
2666
2938
  FROM ${schema}.${table} j
2667
2939
  JOIN ${schema}.queue q ON q.name = COALESCE($2, j.source_name)
2668
- WHERE ${redriveWhere()}
2940
+ WHERE ${redriveWhere(schema, table)}
2669
2941
  ORDER BY j.created_on
2670
2942
  LIMIT $7
2671
2943
  FOR UPDATE OF j SKIP LOCKED
2672
2944
  ),
2673
- moved AS (
2674
- DELETE FROM ${schema}.${table}
2675
- WHERE id IN (SELECT id FROM candidates)
2676
- RETURNING *
2677
- ),
2678
2945
  ins AS (
2679
- INSERT INTO ${schema}.job
2680
- (name, data, priority, retry_limit, retry_backoff, retry_delay, retry_delay_max,
2681
- expire_seconds, start_after, created_on, keep_until, deletion_seconds, policy, singleton_key, group_id, group_tier,
2682
- heartbeat_seconds, dead_letter)
2683
- SELECT COALESCE($2, m.source_name), m.data, m.priority, q.retry_limit, q.retry_backoff,
2684
- q.retry_delay, q.retry_delay_max, q.expire_seconds, ${schema}.job_now(), ${schema}.job_now(),
2685
- ${schema}.job_now() + q.retention_seconds * interval '1s', q.deletion_seconds, q.policy,
2686
- m.singleton_key, m.group_id, m.group_tier, q.heartbeat_seconds, q.dead_letter
2687
- FROM moved m JOIN ${schema}.queue q ON q.name = COALESCE($2, m.source_name)
2946
+ INSERT INTO ${schema}.job ${REDRIVE_INSERT_COLUMNS}
2947
+ SELECT ${redriveInsertValues(schema, 'c.new_id', '$2')}
2948
+ FROM candidates c
2949
+ JOIN ${schema}.${table} m ON m.id = c.id
2950
+ JOIN ${schema}.queue q ON q.name = COALESCE($2, m.source_name)
2688
2951
  ORDER BY m.created_on
2689
2952
  ON CONFLICT DO NOTHING
2690
- RETURNING 1
2953
+ RETURNING id
2954
+ ),
2955
+ settled AS (
2956
+ SELECT c.id, (i.id IS NOT NULL) AS inserted
2957
+ FROM candidates c LEFT JOIN ins i ON i.id = c.new_id
2958
+ ),
2959
+ removed AS (
2960
+ DELETE FROM ${schema}.${table}
2961
+ WHERE id IN (SELECT id FROM settled WHERE inserted)
2962
+ ),
2963
+ conflicted AS (
2964
+ UPDATE ${schema}.${table} j
2965
+ SET ${redriveConflictSet(schema)}
2966
+ FROM settled s, ${schema}.queue q
2967
+ WHERE j.id = s.id
2968
+ AND NOT s.inserted
2969
+ AND q.name = COALESCE($2, j.source_name)
2970
+ AND ${redriveConflictFailable(schema, table, 'm.id IN (SELECT id FROM settled WHERE NOT inserted)')}
2691
2971
  )
2692
2972
  SELECT count(*)::int AS moved FROM ins
2693
2973
  `;
2694
2974
  }
2695
2975
  // Distributed redrive (noMultiMutationCte). CockroachDB refuses redriveJobs' DELETE and INSERT on
2696
- // one table in one statement, so the manager runs the three steps below in a transaction instead:
2697
- // lock the candidates, re-create them, delete the originals. Same predicate, same order, same
2698
- // limit, so the two paths move the same jobs. Insert-then-delete rather than the reverse keeps the
2699
- // rows readable for the INSERT ... SELECT, and the new rows can never match the delete: they get
2700
- // fresh ids. A job whose re-insert hits ON CONFLICT is still deleted, exactly as redriveJobs drops it.
2701
- // Both inserts run oldest-first, so on Postgres the older of two colliding jobs is the one kept, on
2702
- // either path. CockroachDB does not honor that ORDER BY when it resolves ON CONFLICT, so which one it
2703
- // keeps is arbitrary; only the counts are the same there.
2976
+ // one table in one statement, so the manager runs the steps below in a transaction instead: lock
2977
+ // the candidates and give each its new id, re-create them, delete the ones that were re-created,
2978
+ // and fail the rest in place. Same predicate, same order, same limit, so the two
2979
+ // paths move the same jobs. Both inserts run oldest-first, so on Postgres the older of two
2980
+ // colliding jobs is the one kept, on either path. CockroachDB does not honor that ORDER BY when it
2981
+ // resolves ON CONFLICT, so which one it keeps is arbitrary; only the counts are the same there.
2704
2982
  export function selectRedriveCandidates(schema, table) {
2705
2983
  return `
2706
- SELECT j.id
2984
+ SELECT j.id, gen_random_uuid() AS new_id
2707
2985
  FROM ${schema}.${table} j
2708
- WHERE ${redriveWhere()}
2986
+ WHERE ${redriveWhere(schema, table)}
2709
2987
  AND EXISTS (SELECT 1 FROM ${schema}.queue q WHERE q.name = COALESCE($2, j.source_name))
2710
2988
  ORDER BY j.created_on
2711
2989
  LIMIT $7
2712
2990
  FOR UPDATE
2713
2991
  `;
2714
2992
  }
2715
- // $1 candidate ids, $2 destination override.
2993
+ // $1 candidate ids, $2 destination override, $3 the new id for each candidate, in the same order.
2994
+ // Returns the new ids of the jobs that were re-created.
2716
2995
  export function insertRedrivenJobs(schema, table) {
2717
2996
  return `
2718
- INSERT INTO ${schema}.job
2719
- (name, data, priority, retry_limit, retry_backoff, retry_delay, retry_delay_max,
2720
- expire_seconds, start_after, created_on, keep_until, deletion_seconds, policy, singleton_key, group_id, group_tier,
2721
- heartbeat_seconds, dead_letter)
2722
- SELECT COALESCE($2, m.source_name), m.data, m.priority, q.retry_limit, q.retry_backoff,
2723
- q.retry_delay, q.retry_delay_max, q.expire_seconds, ${schema}.job_now(), ${schema}.job_now(),
2724
- ${schema}.job_now() + q.retention_seconds * interval '1s', q.deletion_seconds, q.policy,
2725
- m.singleton_key, m.group_id, m.group_tier, q.heartbeat_seconds, q.dead_letter
2726
- FROM ${schema}.${table} m JOIN ${schema}.queue q ON q.name = COALESCE($2, m.source_name)
2727
- WHERE m.id = ANY($1::uuid[])
2997
+ INSERT INTO ${schema}.job ${REDRIVE_INSERT_COLUMNS}
2998
+ SELECT ${redriveInsertValues(schema, 'p.new_id', '$2')}
2999
+ FROM unnest($1::uuid[], $3::uuid[]) AS p (id, new_id)
3000
+ JOIN ${schema}.${table} m ON m.id = p.id
3001
+ JOIN ${schema}.queue q ON q.name = COALESCE($2, m.source_name)
2728
3002
  ORDER BY m.created_on
2729
3003
  ON CONFLICT DO NOTHING
2730
- RETURNING 1
3004
+ RETURNING id
3005
+ `;
3006
+ }
3007
+ // $1 the dead letter ids that were not re-created, $2 destination override.
3008
+ export function failRedriveConflicts(schema, table) {
3009
+ return `
3010
+ UPDATE ${schema}.${table} j
3011
+ SET ${redriveConflictSet(schema)}
3012
+ FROM ${schema}.queue q
3013
+ WHERE j.id = ANY($1::uuid[])
3014
+ AND q.name = COALESCE($2, j.source_name)
3015
+ AND ${redriveConflictFailable(schema, table, 'm.id = ANY($1::uuid[])')}
2731
3016
  `;
2732
3017
  }
2733
3018
  // What a redrive with the same filter would do, without doing it: matching jobs grouped by the
@@ -2740,7 +3025,7 @@ export function previewRedrive(schema, table) {
2740
3025
  count(*)::int AS count
2741
3026
  FROM ${schema}.${table} j
2742
3027
  LEFT JOIN ${schema}.queue q ON q.name = COALESCE($2, j.source_name)
2743
- WHERE ${redriveWhere()}
3028
+ WHERE ${redriveWhere(schema, table)}
2744
3029
  GROUP BY 1, 2
2745
3030
  `;
2746
3031
  }
@@ -2852,7 +3137,286 @@ export function updateJob(schema, table, name, by, match, notify = false) {
2852
3137
  )${tail}
2853
3138
  `;
2854
3139
  }
2855
- export function getQueueStats(schema, table, queues) {
3140
+ // The throughput window. It starts when this queue was last *counted*, which is not monitor_on:
3141
+ // a pass that doesn't count (persistQueueStats off on that instance, or a forced getQueueStats
3142
+ // refresh) stamps monitor_on too, and windowing on it made every such pass swallow the jobs that
3143
+ // finished before it. Only a statement that counts moves delta_on, so an uncounted pass in between
3144
+ // leaves the window open and the next counted pass picks its jobs up.
3145
+ //
3146
+ // It ends DELTA_LAG behind the pass, not at now(). created_on and completed_on come from job_now(),
3147
+ // the start of the transaction that wrote them, so a job sent inside an application's transaction,
3148
+ // or completed by a transactional worker, carries a stamp from before a pass it commits after. A
3149
+ // window ending at now() stepped past that stamp while the row was still invisible. With the lag,
3150
+ // a transaction that commits within the lag of starting lands ahead of the end, whatever the
3151
+ // backend and whatever role it runs as, and is counted in the window its stamp belongs to. The
3152
+ // counters are DELTA_LAG behind the gauges in the same row, which is what delta_on is for.
3153
+ //
3154
+ // A transaction that runs longer than the lag lands behind a window that has already been counted
3155
+ // and recorded. The pass never waits for it (an open report or backup holding every queue's
3156
+ // counters back is the thing this replaced); the snapshots are trued up after the fact instead.
3157
+ // So the latest hour of history is provisional: a snapshot's counters can rise after it is written,
3158
+ // never fall.
3159
+ //
3160
+ // A window whose start is more than DELTA_RESET_MAX behind its end starts afresh instead: counting
3161
+ // was off for a while, and landing hours of work on one snapshot would chart as a spike at the
3162
+ // moment it was switched back on. Null in, null out, so those comparisons count nothing, the same
3163
+ // as a queue never counted. An instance whose passes are further apart than half of that uses two
3164
+ // of its intervals instead (deltaResetMax), or every window would start afresh and nothing would
3165
+ // ever be counted. Instances that disagree on it can only disagree about whether a gap resets, so
3166
+ // it is safe to derive per instance.
3167
+ //
3168
+ // The lag and the true-up horizon are fixed rather than configured. delta_on is shared by every instance, and a
3169
+ // window that one instance measured with a different lag than the last would count an interval
3170
+ // twice or skip it. The lag only decides how often the true-up runs, not what is counted: measured
3171
+ // under a mixed load whose longest routine transactions held 5s, a lag of 0 or 1s set off a true-up
3172
+ // on 90-97% of passes, and 5s or more on none. Ten seconds leaves headroom over that and puts the
3173
+ // counters within a pass of the gauges. See research/delta-true-up.md on planning.
3174
+ const DELTA_LAG = "interval '10 seconds'";
3175
+ const DELTA_RESET_MAX_SECONDS = 2 * 60 * 60;
3176
+ const DELTA_RESET_MAX = `interval '${DELTA_RESET_MAX_SECONDS} seconds'`;
3177
+ const DELTA_TRUE_UP_MAX = "interval '1 hour'";
3178
+ // The reset threshold for an instance whose counting passes run every intervalSeconds.
3179
+ export function deltaResetMax(intervalSeconds) {
3180
+ return intervalSeconds * 2 > DELTA_RESET_MAX_SECONDS ? `interval '${intervalSeconds * 2} seconds'` : DELTA_RESET_MAX;
3181
+ }
3182
+ function deltaWindowEnd(schema, lag = DELTA_LAG) {
3183
+ return `(${schema}.job_now() - ${lag})`;
3184
+ }
3185
+ function deltaWindowStart(alias, end, resetMax = DELTA_RESET_MAX) {
3186
+ return `(CASE WHEN ${alias}.delta_on > ${end} - ${resetMax} THEN ${alias}.delta_on END)`;
3187
+ }
3188
+ // The SET clause for a statement that counts. The right-hand side reads the row as it was before
3189
+ // the update, so delta_seconds is measured from the same window the counts used, and an idle queue
3190
+ // (no job rows, so no stats row) still records zero over a real number of seconds. The window never
3191
+ // moves backwards, even if the end lands before the last one.
3192
+ function throughputAssignments(end, resetMax) {
3193
+ const start = deltaWindowStart('queue', end, resetMax);
3194
+ // CASE rather than GREATEST(0, ...): GREATEST skips NULLs, and a pass with no window has to
3195
+ // record null seconds, not zero.
3196
+ const seconds = `round(extract(epoch from (${end} - ${start})))::int`;
3197
+ return `
3198
+ created_delta = COALESCE(stats."createdDelta", 0),
3199
+ completed_delta = COALESCE(stats."completedDelta", 0),
3200
+ failed_delta = COALESCE(stats."failedDelta", 0),
3201
+ delta_seconds = CASE WHEN ${seconds} < 0 THEN 0 ELSE ${seconds} END,
3202
+ delta_on = GREATEST(queue.delta_on, ${end}),`;
3203
+ }
3204
+ // The windows a true-up may still revise, per queue: every recorded snapshot whose window ends
3205
+ // after the anchor `h`, up to the newest one, `top`, with the counters they hold between them.
3206
+ //
3207
+ // A snapshot's window is not stored; it is rebuilt. Each counted pass starts where the last one
3208
+ // ended and records its end as delta_on, so a snapshot's window runs from the previous snapshot's
3209
+ // delta_on to its own, exactly, with no rounding (delta_seconds is rounded, so start = delta_on -
3210
+ // delta_seconds would not tile). The anchor is the newest snapshot at least DELTA_TRUE_UP_MAX
3211
+ // behind, so only windows younger than the horizon are revised. A snapshot that started a window
3212
+ // afresh (first counted pass, or a reset after a gap; both record null seconds) counted nothing
3213
+ // before its end on purpose, so it anchors too: truing up across it would land hours of work on it.
3214
+ //
3215
+ // The recount only sees rows that still exist, so the anchor also stays inside the queue's
3216
+ // retention. Retention deletes a finished job once completed_on + deletion_seconds has passed, and
3217
+ // a queued one once keep_until (start_after + retention_seconds) has. Rows it removed from a window
3218
+ // that was already counted make the recount fall short of the snapshots, and on a queue that
3219
+ // deletes within the hour that shortfall hides every late commit. The anchor is therefore never
3220
+ // earlier than the first window end past job_now() minus the shorter of the two, where nothing has
3221
+ // been deleted yet; a window straddling that point is not revised.
3222
+ //
3223
+ // When a pass counted but its snapshot was never written (the insert is a separate statement and
3224
+ // can fail), the next snapshot's rebuilt window covers both passes. Its stored counters then fall
3225
+ // short by that pass's jobs, and the true-up restores them there, which is the right place to the
3226
+ // resolution the history has.
3227
+ // The anchor rule and the reach, shared so trueUpWindows (the fix) and trueUpSettled (the monitor's
3228
+ // check) always agree on which windows are revised. `s` is a queue_stats row.
3229
+ function trueUpAnchor(schema, trueUpMax) {
3230
+ return `s.delta_on <= ${schema}.job_now() - ${trueUpMax} OR s.delta_seconds IS NULL`;
3231
+ }
3232
+ // `q` is the queue row. Per-job overrides of either retention are not seen here.
3233
+ function trueUpRetentionFloor(schema, q) {
3234
+ return `s.delta_on >= ${schema}.job_now() - (CASE WHEN ${q}.deletion_seconds > 0 AND ${q}.deletion_seconds < ${q}.retention_seconds
3235
+ THEN ${q}.deletion_seconds ELSE ${q}.retention_seconds END) * interval '1s'`;
3236
+ }
3237
+ // The anchor from the horizon rule `b` and the retention floor `f`, whichever is later. A null floor
3238
+ // means no window ends inside the retention, so there is nothing to revise and the anchor is null.
3239
+ function trueUpAnchorAt(b, f) {
3240
+ return `CASE WHEN ${f} IS NULL OR ${f} > ${b} THEN ${f} ELSE ${b} END`;
3241
+ }
3242
+ function trueUpReach(schema, trueUpMax) {
3243
+ return `s.captured_on >= ${schema}.job_now() - 2 * ${trueUpMax} AND s.delta_on IS NOT NULL`;
3244
+ }
3245
+ // Every recorded snapshot in reach, each carrying its queue's anchor `h`; the windows are the rows
3246
+ // with delta_on > h. One read of queue_stats: the anchor is a window aggregate over the same rows.
3247
+ // CASE rather than FILTER, which CockroachDB doesn't take on a window function.
3248
+ function trueUpWindows(schema, queues, trueUpMax = DELTA_TRUE_UP_MAX) {
3249
+ return `
3250
+ SELECT x.id, x.captured_on, x.name, x.delta_on, x.created_delta, x.completed_delta, x.failed_delta,
3251
+ ${trueUpAnchorAt('x.b', 'x.f')} AS h
3252
+ FROM (
3253
+ SELECT s.id, s.captured_on, s.name, s.delta_on, s.created_delta, s.completed_delta, s.failed_delta,
3254
+ COALESCE(
3255
+ max(CASE WHEN ${trueUpAnchor(schema, trueUpMax)} THEN s.delta_on END)
3256
+ OVER (PARTITION BY s.name),
3257
+ min(s.delta_on) OVER (PARTITION BY s.name)
3258
+ ) AS b,
3259
+ min(CASE WHEN ${trueUpRetentionFloor(schema, 'q')} THEN s.delta_on END) OVER (PARTITION BY s.name) AS f
3260
+ FROM ${schema}.queue_stats s
3261
+ JOIN ${schema}.queue q ON q.name = s.name
3262
+ WHERE s.name = ANY(${queues}) AND ${trueUpReach(schema, trueUpMax)}
3263
+ ) x`;
3264
+ }
3265
+ // The same anchor and windows, reduced to what the monitor's check needs: per queue, the anchor,
3266
+ // the newest window end `top`, and what the windows between them hold. Two index lookups per queue
3267
+ // on queue_stats (name, captured_on), laterally, rather than trueUpWindows' window aggregate: that
3268
+ // sorts every snapshot in reach by queue name, which on a thousand queues cost more than the check.
3269
+ function trueUpSettled(schema, alias, trueUpMax = DELTA_TRUE_UP_MAX) {
3270
+ return `
3271
+ LEFT JOIN LATERAL (
3272
+ SELECT ${trueUpAnchorAt('x.b', 'x.f')} AS h
3273
+ FROM (
3274
+ SELECT COALESCE(
3275
+ max(s.delta_on) FILTER (WHERE ${trueUpAnchor(schema, trueUpMax)}),
3276
+ min(s.delta_on)
3277
+ ) AS b,
3278
+ min(s.delta_on) FILTER (WHERE ${trueUpRetentionFloor(schema, alias)}) AS f
3279
+ FROM ${schema}.queue_stats s
3280
+ WHERE s.name = ${alias}.name AND ${trueUpReach(schema, trueUpMax)}
3281
+ ) x
3282
+ ) a ON true
3283
+ LEFT JOIN LATERAL (
3284
+ SELECT max(s.delta_on) AS top,
3285
+ sum(s.created_delta + s.completed_delta + s.failed_delta) AS settled
3286
+ FROM ${schema}.queue_stats s
3287
+ WHERE s.name = ${alias}.name AND ${trueUpReach(schema, trueUpMax)} AND s.delta_on > a.h
3288
+ ) t ON true`;
3289
+ }
3290
+ // Revise the recent snapshots' counters for jobs that committed after the pass that counted their
3291
+ // window. Run only for the queues the monitor's aggregate flagged:
3292
+ // that check is three filtered counts riding a scan the monitor makes anyway, and this is a second
3293
+ // scan of the queue's rows, paid once per late commit rather than on every pass.
3294
+ //
3295
+ // Each job row is placed in its window by width_bucket over the queue's window ends, a binary
3296
+ // search rather than a join against every window, then counted per window and compared with what
3297
+ // the snapshot holds. The thresholds are [h, end1, end2, ...], so width_bucket returns i for
3298
+ // h <= stamp < end_i, the window of the snapshot ordered i, and past the last end a bucket no
3299
+ // window claims. Counters only rise: retention and deleteJob() remove rows a window already
3300
+ // counted, and a recount that went down would unwrite them. Two instances truing up the same
3301
+ // snapshot write the same GREATEST, so nothing here needs to be serialized for correctness; the
3302
+ // stats try-lock is taken so they don't both scan. One row when it scanned, none when another
3303
+ // instance held the lock, the same contract as the aggregate's pinSeconds.
3304
+ export function trueUpQueueStats(schema, table, queues, noAdvisoryLocks, window = {}) {
3305
+ const names = serializeArrayParam(queues);
3306
+ const trueUpMax = window.trueUpMax ?? DELTA_TRUE_UP_MAX;
3307
+ const lock = tryAdvisoryLock(schema, 'queue-stats', noAdvisoryLocks);
3308
+ const sql = `
3309
+ WITH ${lock.cte}win AS (
3310
+ SELECT w.*, row_number() OVER (PARTITION BY w.name ORDER BY w.delta_on, w.captured_on) AS i
3311
+ FROM (${trueUpWindows(schema, names, trueUpMax)}) w
3312
+ WHERE w.delta_on > w.h${lock.guard}
3313
+ ),
3314
+ th AS (
3315
+ SELECT w.name, w.h, array_prepend(w.h, array_agg(w.delta_on ORDER BY w.i)) AS t
3316
+ FROM win w
3317
+ GROUP BY w.name, w.h
3318
+ ),
3319
+ binned AS (
3320
+ SELECT j.name,
3321
+ CASE WHEN j.created_on >= th.h THEN width_bucket(j.created_on, th.t) END AS cb,
3322
+ CASE WHEN j.state IN ('${JOB_STATES.completed}', '${JOB_STATES.failed}') AND j.completed_on >= th.h
3323
+ THEN width_bucket(j.completed_on, th.t) END AS fb,
3324
+ j.state
3325
+ FROM ${schema}.${table} j
3326
+ JOIN th ON th.name = j.name
3327
+ WHERE j.name = ANY(${names})
3328
+ ),
3329
+ grouped AS (
3330
+ SELECT name, cb, fb, state, count(*)::int AS n
3331
+ FROM binned
3332
+ WHERE cb IS NOT NULL OR fb IS NOT NULL
3333
+ GROUP BY 1, 2, 3, 4
3334
+ ),
3335
+ counted AS (
3336
+ SELECT name, cb AS i, sum(n)::int AS created, 0 AS completed, 0 AS failed
3337
+ FROM grouped WHERE cb IS NOT NULL GROUP BY 1, 2
3338
+ UNION ALL
3339
+ SELECT name, fb AS i, 0,
3340
+ COALESCE(sum(n) FILTER (WHERE state = '${JOB_STATES.completed}'), 0)::int,
3341
+ COALESCE(sum(n) FILTER (WHERE state = '${JOB_STATES.failed}'), 0)::int
3342
+ FROM grouped WHERE fb IS NOT NULL GROUP BY 1, 2
3343
+ ),
3344
+ recount AS (
3345
+ SELECT w.id, w.captured_on,
3346
+ sum(c.created)::int AS created, sum(c.completed)::int AS completed, sum(c.failed)::int AS failed
3347
+ FROM win w
3348
+ JOIN counted c ON c.name = w.name AND c.i = w.i
3349
+ GROUP BY w.id, w.captured_on
3350
+ ),
3351
+ raised AS (
3352
+ UPDATE ${schema}.queue_stats s SET
3353
+ created_delta = GREATEST(s.created_delta, r.created),
3354
+ completed_delta = GREATEST(s.completed_delta, r.completed),
3355
+ failed_delta = GREATEST(s.failed_delta, r.failed)
3356
+ FROM recount r
3357
+ WHERE s.id = r.id AND s.captured_on = r.captured_on${lock.guard}
3358
+ AND (r.created > s.created_delta OR r.completed > s.completed_delta OR r.failed > s.failed_delta)
3359
+ RETURNING s.id
3360
+ )
3361
+ SELECT (SELECT count(*) FROM raised)::int AS raised, ${PIN_SECONDS_SQL} as "pinSeconds"
3362
+ WHERE true${lock.guard}
3363
+ `;
3364
+ return transaction(sql);
3365
+ }
3366
+ // Every count the monitor keeps, from one pass over the queue's table.
3367
+ //
3368
+ // Six of them are gauges — what the queue looks like right now. Three are not:
3369
+ // createdDelta counts the jobs created since the last pass, and completedDelta
3370
+ // and failedDelta the jobs that *finished*, which is the only way to answer
3371
+ // "how many jobs did this queue get through" from a table of current state.
3372
+ // Five hundred arriving and five hundred leaving looks identical to a still
3373
+ // queue in every gauge here.
3374
+ //
3375
+ // The window is the queue's own `delta_on`, joined in
3376
+ // rather than passed as a fixed interval. That watermark is what makes the three
3377
+ // counters exact across a skipped or backed-off pass: nothing is counted twice,
3378
+ // because the window starts where the last one ended, and nothing is missed,
3379
+ // because a late pass simply covers a longer window, and says so in
3380
+ // delta_seconds. A queue that has never been counted has a null watermark and
3381
+ // counts zero, which is the honest answer for a first pass that has nothing to
3382
+ // compare against.
3383
+ //
3384
+ // The join is against `queue`, which holds one row per queue — Postgres hashes
3385
+ // it once and probes per row. The alternative, a second pass over the job table
3386
+ // filtered on completed_on, would be a whole extra scan of the largest table in
3387
+ // the schema, and there is no index on that column to make it cheaper.
3388
+ export function getQueueStats(schema, table, queues, throughput = false, window = {}) {
3389
+ // Counted only with persistQueueStats. Otherwise the aggregate does what it did before throughput
3390
+ // existed: no join, no extra counts, no cost. The measured price is in the `persistQueueStats` docs.
3391
+ const end = deltaWindowEnd(schema, window.lag);
3392
+ const inWindow = (column) => `j.${column} >= ${deltaWindowStart('q', end, window.resetMax)} AND j.${column} < ${end}`;
3393
+ // The true-up check: the same jobs recounted across the windows already recorded (see
3394
+ // trueUpSettled). More than the snapshots hold means something committed after its window was
3395
+ // counted, and the monitor follows up with trueUpQueueStats for this queue.
3396
+ const settled = (column) => `j.${column} >= q.h AND j.${column} < q.top`;
3397
+ const counters = throughput
3398
+ ? {
3399
+ select: `
3400
+ "createdDelta",
3401
+ "completedDelta",
3402
+ "failedDelta",
3403
+ COALESCE("recount" > "settled", false) as "trueUp",`,
3404
+ counts: `
3405
+ (count(*) FILTER (WHERE ${inWindow('created_on')}))::int as "createdDelta",
3406
+ (count(*) FILTER (WHERE j.state = '${JOB_STATES.completed}' AND ${inWindow('completed_on')}))::int as "completedDelta",
3407
+ (count(*) FILTER (WHERE j.state = '${JOB_STATES.failed}' AND ${inWindow('completed_on')}))::int as "failedDelta",
3408
+ sum(
3409
+ CASE WHEN ${settled('created_on')} THEN 1 ELSE 0 END +
3410
+ CASE WHEN ${settled('completed_on')} AND j.state IN ('${JOB_STATES.completed}', '${JOB_STATES.failed}') THEN 1 ELSE 0 END
3411
+ )::int as "recount",
3412
+ max(q.settled) as "settled",`,
3413
+ join: `JOIN (
3414
+ SELECT q.name, q.delta_on, a.h, t.top, t.settled
3415
+ FROM ${schema}.queue q${trueUpSettled(schema, 'q', window.trueUpMax)}
3416
+ WHERE q.name = ANY($1::text[])
3417
+ ) q ON q.name = j.name`
3418
+ }
3419
+ : { select: '', counts: '', join: '' };
2856
3420
  return {
2857
3421
  text: `
2858
3422
  SELECT
@@ -2862,19 +3426,20 @@ export function getQueueStats(schema, table, queues) {
2862
3426
  GREATEST("queuedCount" - "deferredCount", 0) as "readyCount",
2863
3427
  "activeCount",
2864
3428
  "failedCount",
2865
- "totalCount",
3429
+ "totalCount",${counters.select}
2866
3430
  "singletonsActive"
2867
3431
  FROM (
2868
3432
  SELECT
2869
- name,
2870
- (count(*) FILTER (WHERE start_after > ${schema}.job_now() AND state < '${JOB_STATES.active}'))::int as "deferredCount",
2871
- (count(*) FILTER (WHERE state < '${JOB_STATES.active}'))::int as "queuedCount",
2872
- (count(*) FILTER (WHERE state = '${JOB_STATES.active}'))::int as "activeCount",
2873
- (count(*) FILTER (WHERE state = '${JOB_STATES.failed}'))::int as "failedCount",
2874
- count(*)::int as "totalCount",
2875
- array_agg(singleton_key) FILTER (WHERE policy IN ('${QUEUE_POLICIES.singleton}','${QUEUE_POLICIES.stately}') AND state = '${JOB_STATES.active}') as "singletonsActive"
2876
- FROM ${schema}.${table}
2877
- WHERE name = ANY($1::text[])
3433
+ j.name,
3434
+ (count(*) FILTER (WHERE j.start_after > ${schema}.job_now() AND j.state < '${JOB_STATES.active}'))::int as "deferredCount",
3435
+ (count(*) FILTER (WHERE j.state < '${JOB_STATES.active}'))::int as "queuedCount",
3436
+ (count(*) FILTER (WHERE j.state = '${JOB_STATES.active}'))::int as "activeCount",
3437
+ (count(*) FILTER (WHERE j.state = '${JOB_STATES.failed}'))::int as "failedCount",
3438
+ count(*)::int as "totalCount",${counters.counts}
3439
+ array_agg(j.singleton_key) FILTER (WHERE j.policy IN ('${QUEUE_POLICIES.singleton}','${QUEUE_POLICIES.stately}') AND j.state = '${JOB_STATES.active}') as "singletonsActive"
3440
+ FROM ${schema}.${table} j
3441
+ ${counters.join}
3442
+ WHERE j.name = ANY($1::text[])
2878
3443
  GROUP BY 1
2879
3444
  ) stats
2880
3445
  `,
@@ -2889,10 +3454,13 @@ export const READY_HISTORY_SIZE = 60;
2889
3454
  /* eslint-disable no-restricted-syntax -- how long this transaction has held its snapshot: real elapsed time, not job time */
2890
3455
  const PIN_SECONDS_SQL = 'EXTRACT(EPOCH FROM (clock_timestamp() - transaction_timestamp()))::float8';
2891
3456
  /* eslint-enable no-restricted-syntax */
2892
- export function cacheQueueStats(schema, table, queues, noAdvisoryLocks) {
2893
- const statsQuery = getQueueStats(schema, table, queues);
3457
+ export function cacheQueueStats(schema, table, queues, noAdvisoryLocks, throughput, window = {}) {
3458
+ const statsQuery = getQueueStats(schema, table, queues, throughput, window);
3459
+ // The aggregate only produces these when counting, so the assignment has to
3460
+ // disappear with them rather than reference a column that is not there.
3461
+ const throughputSet = throughput ? throughputAssignments(deltaWindowEnd(schema, window.lag), window.resetMax) : '';
2894
3462
  // Serialize the $1 parameter for use in the multi-statement transaction below
2895
- const statsText = statsQuery.text.replace('$1::text[]', serializeArrayParam(queues));
3463
+ const statsText = statsQuery.text.replaceAll('$1::text[]', serializeArrayParam(queues));
2896
3464
  const lock = tryAdvisoryLock(schema, 'queue-stats', noAdvisoryLocks);
2897
3465
  // Two columns in here are not counts and are easy to mistake for incidental:
2898
3466
  //
@@ -2915,6 +3483,10 @@ export function cacheQueueStats(schema, table, queues, noAdvisoryLocks) {
2915
3483
  // volatile, so this is evaluated as rows are returned - after the aggregate CTE has run, which is
2916
3484
  // the part that pins. It undercounts the tail (the remaining rows and the COMMIT), the safe
2917
3485
  // direction to be wrong in.
3486
+ //
3487
+ // The queue rows are locked in name order (see queueRowLock) by the subquery that feeds the update.
3488
+ // Its count over stats is there only to finish the aggregate before the first row is locked, so no
3489
+ // queue row stays locked through the job table scan.
2918
3490
  const sql = `
2919
3491
  WITH ${lock.cte}stats AS (SELECT * FROM (${statsText}) agg WHERE true${lock.guard})
2920
3492
  UPDATE ${schema}.queue SET
@@ -2923,7 +3495,7 @@ export function cacheQueueStats(schema, table, queues, noAdvisoryLocks) {
2923
3495
  ready_count = COALESCE(stats."readyCount", 0),
2924
3496
  active_count = COALESCE(stats."activeCount", 0),
2925
3497
  failed_count = COALESCE(stats."failedCount", 0),
2926
- total_count = COALESCE(stats."totalCount", 0),
3498
+ total_count = COALESCE(stats."totalCount", 0),${throughputSet}
2927
3499
  singletons_active = stats."singletonsActive",
2928
3500
  monitor_on = ${schema}.job_now(),
2929
3501
  ready_history = (
@@ -2941,15 +3513,19 @@ export function cacheQueueStats(schema, table, queues, noAdvisoryLocks) {
2941
3513
  ) capped
2942
3514
  )
2943
3515
  FROM (
2944
- SELECT q.name
2945
- FROM unnest(${serializeArrayParam(queues)}) AS q(name)
3516
+ SELECT name
3517
+ FROM ${schema}.queue
3518
+ WHERE name = ANY(${serializeArrayParam(queues)})${lock.guard}
3519
+ AND (SELECT count(*) FROM stats) >= 0
3520
+ ORDER BY name
3521
+ FOR NO KEY UPDATE
2946
3522
  ) q
2947
3523
  LEFT JOIN stats ON stats.name = q.name
2948
3524
  WHERE queue.name = q.name${lock.guard}
2949
3525
  RETURNING
2950
3526
  queue.name,
2951
3527
  queue.queued_count as "queuedCount",
2952
- queue.warning_queued as "warningQueueSize",
3528
+ queue.warning_queued as "warningQueueSize",${throughput ? '\n COALESCE(stats."trueUp", false) as "trueUp",' : ''}
2953
3529
  ${PIN_SECONDS_SQL} as "pinSeconds"
2954
3530
  `;
2955
3531
  // transaction(), not locked(): the lock is taken inside the statement with try rather than by a
@@ -2979,6 +3555,9 @@ export function cacheQueueStats(schema, table, queues, noAdvisoryLocks) {
2979
3555
  // aggregate anywhere in the schema, which on exactly the slow-aggregate deployments this subsystem
2980
3556
  // targets is not a rare race. Skipping the lock is bounded: it can happen at most once per queue,
2981
3557
  // because the scan it runs is what populates the cache that gates every later read.
3558
+ //
3559
+ // It never counts throughput. Only the monitor does, so there is one writer of the counting window;
3560
+ // this leaves delta_on alone and the next monitor pass counts across it.
2982
3561
  export function refreshQueueStats(schema, table, name, options = {}) {
2983
3562
  const statsQuery = getQueueStats(schema, table, [name]);
2984
3563
  const statsText = statsQuery.text.replace('$1::text[]', serializeArrayParam([name]));
@@ -3008,6 +3587,11 @@ export function refreshQueueStats(schema, table, name, options = {}) {
3008
3587
  queue.active_count as "activeCount",
3009
3588
  queue.failed_count as "failedCount",
3010
3589
  queue.total_count as "totalCount",
3590
+ queue.created_delta as "createdDelta",
3591
+ queue.completed_delta as "completedDelta",
3592
+ queue.failed_delta as "failedDelta",
3593
+ queue.delta_seconds as "deltaSeconds",
3594
+ queue.delta_on as "deltaOn",
3011
3595
  queue.monitor_on as "capturedOn"
3012
3596
  `;
3013
3597
  }
@@ -3037,7 +3621,7 @@ export function locked(schema, query, key, noAdvisoryLocks) {
3037
3621
  // normalizeSchemaName, not resolveSchemaName: the key is opaque to postgres and never compared
3038
3622
  // against the catalog, so it only has to agree across instances on the same schema. See the note
3039
3623
  // on the helper.
3040
- function advisoryLockKey(schema, key) {
3624
+ export function advisoryLockKey(schema, key) {
3041
3625
  return `('x' || encode(sha224((current_database() || '.pgboss.${normalizeSchemaName(schema)}${key || ''}')::bytea), 'hex'))::bit(64)::bigint`;
3042
3626
  }
3043
3627
  function advisoryLock(schema, key) {
@@ -3330,7 +3914,7 @@ export function releaseBamCommand(schema, id, priorStatus, priorStartedOn, claim
3330
3914
  ? 'NULL'
3331
3915
  : `'${priorStartedOn.replace(SINGLE_QUOTE_REGEX, "''")}'::timestamptz`;
3332
3916
  // Compare-and-swap on the claim this runner actually took, not on the id alone. The timeout-only
3333
- // claim has no SKIP LOCKED (see getNextBamCommand), so two overlapping claims can both return the
3917
+ // claim has no SKIP LOCKED, so two overlapping claims can both return the
3334
3918
  // same row - verified: both get rowCount 1 and the same prior_status. Releasing on the id alone
3335
3919
  // would then reset a row a peer is actively building back to 'pending', and the next poll would
3336
3920
  // start a second CREATE INDEX CONCURRENTLY on the same index. Matching started_on makes the release
@@ -3402,7 +3986,7 @@ export function bamCommandIndexName(command) {
3402
3986
  return match ? match[1] : null;
3403
3987
  }
3404
3988
  // job_iN partial indexes that gate on a queue policy: a per-queue partition table (partition:true)
3405
- // only receives the index for its own policy (see create_queue, createQueueFunction). The shared
3989
+ // only receives the index for its own policy. The shared
3406
3990
  // job_common table and a non-partitioned job table carry all of them at once. Keep in sync with the
3407
3991
  // createIndexJobPolicy* builders and the ELSIF ladder in createQueueFunction.
3408
3992
  const POLICY_JOB_INDEXES = {
@@ -3414,9 +3998,9 @@ const POLICY_JOB_INDEXES = {
3414
3998
  10: QUEUE_POLICIES.key_strict_fifo
3415
3999
  };
3416
4000
  // job_iN indexes with no policy gate, created on every job table regardless of policy
3417
- // (throttle i4, fetch i11, group-concurrency i7, blocking i9). 5 is absent, not missing: the fetch
3418
- // index was replaced in v40 and the retired number is not reused.
3419
- const BASE_JOB_INDEXES = [4, 7, 9, 11];
4001
+ // (throttle i4, fetch i11, group-concurrency i7, blocking i9, source root i12). 5 is absent, not
4002
+ // missing: the fetch index was replaced in v40 and the retired number is not reused.
4003
+ const BASE_JOB_INDEXES = [4, 7, 9, 11, 12];
3420
4004
  // The fixed (non-job) managed tables; job/job_common/partitions are handled separately.
3421
4005
  const FIXED_MANAGED_TABLES = ['version', 'queue', 'schedule', 'subscription', 'bam', 'warning', 'queue_stats', 'job_dependency'];
3422
4006
  // Selects the manifest section for the live architecture, and substitutes the real schema name back in