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/README.md +1 -2
- package/dist/activity.d.ts +15 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +95 -0
- package/dist/attorney.d.ts.map +1 -1
- package/dist/attorney.js +1 -0
- package/dist/boss.d.ts.map +1 -1
- package/dist/boss.js +50 -13
- package/dist/claimTimer.d.ts.map +1 -1
- package/dist/claimTimer.js +19 -1
- package/dist/clock.d.ts +10 -7
- package/dist/clock.d.ts.map +1 -1
- package/dist/clock.js +67 -38
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -6
- package/dist/manager.d.ts +6 -5
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +186 -67
- package/dist/migrationStore.d.ts.map +1 -1
- package/dist/migrationStore.js +166 -3
- package/dist/navigator.d.ts.map +1 -1
- package/dist/navigator.js +4 -0
- package/dist/plans.d.ts +22 -13
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +786 -202
- package/dist/schema.json +196 -2
- package/dist/timekeeper.d.ts +2 -1
- package/dist/timekeeper.d.ts.map +1 -1
- package/dist/timekeeper.js +17 -8
- package/dist/types.d.ts +89 -88
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
|
306
|
-
//
|
|
307
|
-
//
|
|
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 (${
|
|
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
|
|
534
|
-
// where a job in a dead-letter queue came from, stamped at the transfer
|
|
535
|
-
// id, enqueue time
|
|
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
|
-
|
|
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
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
//
|
|
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
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
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
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
|
|
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
|
-
|
|
980
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
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,
|
|
1454
|
-
|
|
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.
|
|
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
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
|
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
|
-
|
|
1936
|
-
RETURNING
|
|
2124
|
+
${recordsetAttemptFence('j.', 'i')}
|
|
2125
|
+
RETURNING j.id
|
|
1937
2126
|
)
|
|
1938
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2372
|
+
${fenced ? attemptFence(3) : ''}
|
|
2373
|
+
RETURNING id
|
|
2183
2374
|
)
|
|
2184
|
-
|
|
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,
|
|
2392
|
-
expire_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.
|
|
2411
|
-
r.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
2625
|
-
expire_seconds,
|
|
2626
|
-
|
|
2627
|
-
SELECT $1, $2, $
|
|
2628
|
-
q.expire_seconds, $
|
|
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
|
-
|
|
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
|
-
//
|
|
2646
|
-
//
|
|
2647
|
-
//
|
|
2648
|
-
//
|
|
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
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2660
|
-
|
|
2661
|
-
|
|
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
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
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
|
|
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
|
|
2697
|
-
//
|
|
2698
|
-
//
|
|
2699
|
-
//
|
|
2700
|
-
//
|
|
2701
|
-
//
|
|
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
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2945
|
-
FROM
|
|
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
|
|
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
|
|
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
|
|
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
|