@ultimat3/jobs 23.0.0 → 25.0.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/CLAUDE.md +72 -75
- package/README.md +83 -38
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +25 -3
- package/src/backfill-pass.ts +22 -5
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/claim-exhausted.ts +76 -0
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +34 -12
- package/src/driver-memory.ts +80 -38
- package/src/driver-pg-jobs-sql.ts +27 -19
- package/src/driver-pg-operator-sql.ts +32 -12
- package/src/driver-pg-operator.ts +19 -3
- package/src/driver-pg-rows.ts +3 -3
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +99 -30
- package/src/driver-pg.ts +103 -98
- package/src/driver.ts +46 -16
- package/src/errors-concurrency.ts +1 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +9 -17
- package/src/events-pg.ts +11 -21
- package/src/events.ts +26 -15
- package/src/execute.ts +47 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +45 -99
- package/src/inspect-operator.ts +4 -4
- package/src/inspect.ts +5 -4
- package/src/introspection.ts +23 -11
- package/src/job.ts +6 -7
- package/src/leases.ts +1 -1
- package/src/limits.ts +46 -15
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +6 -7
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +47 -19
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +19 -16
- package/src/scheduler-state.ts +10 -3
- package/src/scheduler.ts +14 -15
- package/src/steps-memory.ts +2 -2
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +90 -88
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +64 -118
- package/src/worker-admit.ts +14 -6
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +82 -45
- package/src/worker-held.ts +100 -0
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-options.ts +19 -15
- package/src/worker-registry.ts +16 -1
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +124 -73
- package/src/driver-nats.ts +0 -86
- package/src/driver-redis.ts +0 -89
|
@@ -104,14 +104,35 @@ select ${JOB_ROW_COLUMNS}
|
|
|
104
104
|
* (`driver.ts`) is the same set; a row this matches nothing for was refused before it was sent,
|
|
105
105
|
* or raced to live in between. `woke` announces the row in the statement that made it claimable
|
|
106
106
|
* (`driver-pg-wake-sql.ts`): `x jobs retry` is a job starting now, not at an idle worker's poll.
|
|
107
|
+
*
|
|
108
|
+
* $2 is `fromStep`, or null — and the steps are dropped HERE, off the row this statement moved.
|
|
109
|
+
* They were a statement of their own, sent first: a requeue that then matched nothing (the row
|
|
110
|
+
* raced to live) had already deleted a running job's steps, and a crash between the two left a
|
|
111
|
+
* dead letter with its steps gone and no retry queued.
|
|
112
|
+
*
|
|
113
|
+
* "From that step onward": the step `fromStep` names and every step that started AFTER it.
|
|
114
|
+
* Deleting the named step alone left later steps replaying results computed from the old run.
|
|
115
|
+
* Strictly after, plus the step itself: a step sharing the target's millisecond is not provably
|
|
116
|
+
* later, and re-running an EARLIER step — the charge before the receipt — is the worse mistake.
|
|
107
117
|
*/
|
|
108
118
|
export const SQL_JOB_REQUEUE = `
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
with requeued as (
|
|
120
|
+
update x_jobs
|
|
121
|
+
set state = 'ready', attempt = 0, run_at = now(), updated_at = now(),
|
|
122
|
+
claimed_by = null, visible_at = null
|
|
123
|
+
where id = $1 and state in ('dead', 'cancelled', 'done', 'failed')
|
|
124
|
+
returning ${JOB_ROW_COLUMNS},
|
|
125
|
+
pg_notify('${JOBS_WAKE_CHANNEL}', queue)::text as woke
|
|
126
|
+
), dropped as (
|
|
127
|
+
delete from x_job_steps s
|
|
128
|
+
using requeued r
|
|
129
|
+
where $2::text is not null
|
|
130
|
+
and s.run_id = r.run_id
|
|
131
|
+
and (s.name = $2::text
|
|
132
|
+
or s.started_at > (select t.started_at from x_job_steps t
|
|
133
|
+
where t.run_id = r.run_id and t.name = $2::text))
|
|
134
|
+
)
|
|
135
|
+
select ${JOB_ROW_NAMES}, woke from requeued
|
|
115
136
|
`.trim();
|
|
116
137
|
|
|
117
138
|
/**
|
|
@@ -124,16 +145,3 @@ select id from x_jobs
|
|
|
124
145
|
and id <> $4 and state in ('ready', 'delayed', 'running', 'suspended')
|
|
125
146
|
limit 1
|
|
126
147
|
`.trim();
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* "From that step onward": the step `fromStep` names and every step that started AFTER it.
|
|
130
|
-
* Deleting the named step alone left later steps replaying results computed from the old run.
|
|
131
|
-
* Strictly after, plus the step itself: a step sharing the target's millisecond is not provably
|
|
132
|
-
* later, and re-running an EARLIER step — the charge before the receipt — is the worse mistake.
|
|
133
|
-
*/
|
|
134
|
-
export const SQL_STEPS_FROM = `
|
|
135
|
-
delete from x_job_steps
|
|
136
|
-
where run_id = $1
|
|
137
|
-
and (name = $2
|
|
138
|
-
or started_at > (select started_at from x_job_steps where run_id = $1 and name = $2))
|
|
139
|
-
`.trim();
|
|
@@ -15,15 +15,18 @@ const bulkMatch = (alias = ''): string =>
|
|
|
15
15
|
`${alias}state = $1 and ($2::text is null or ${alias}queue = $2) and ($3::text is null or ${alias}name = $3) and ($5::text is null or ${alias}tenant_id = $5)`;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Re-queue up to $4 finished rows. `
|
|
19
|
-
* of
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
18
|
+
* Re-queue up to $4 finished rows. `eligible` is every row of the filter no LIVE job holds the key
|
|
19
|
+
* of — the single requeue's `X_JOB_DUPLICATE`, answered here as "not this row" — and `distinct on`
|
|
20
|
+
* the idempotency namespace picks one of them per key, because two dead rows of one key would both
|
|
21
|
+
* go `ready` and the second would break the live index.
|
|
22
|
+
*
|
|
23
|
+
* `remaining` is what a second call could still move, counted in the same statement off the same
|
|
24
|
+
* snapshot: the eligible rows whose key this call did not just take. A held row was counted too,
|
|
25
|
+
* so a caller told to "call again until it is zero" never stopped.
|
|
23
26
|
*/
|
|
24
27
|
export const SQL_JOB_REQUEUE_MANY = `
|
|
25
|
-
with
|
|
26
|
-
select
|
|
28
|
+
with eligible as (
|
|
29
|
+
select j.id, j.name, coalesce(j.tenant_id, '') as tenant, j.idempotency_key, j.created_at
|
|
27
30
|
from x_jobs j
|
|
28
31
|
where ${bulkMatch('j.')}
|
|
29
32
|
and not exists (
|
|
@@ -33,7 +36,10 @@ with candidates as (
|
|
|
33
36
|
and l.idempotency_key = j.idempotency_key
|
|
34
37
|
and l.state in ${LIVE}
|
|
35
38
|
)
|
|
36
|
-
|
|
39
|
+
), candidates as (
|
|
40
|
+
select distinct on (name, tenant, idempotency_key) id, name, tenant, idempotency_key
|
|
41
|
+
from eligible
|
|
42
|
+
order by name, tenant, idempotency_key, created_at
|
|
37
43
|
limit $4
|
|
38
44
|
), moved as (
|
|
39
45
|
update x_jobs
|
|
@@ -43,7 +49,13 @@ with candidates as (
|
|
|
43
49
|
returning id
|
|
44
50
|
)
|
|
45
51
|
select (select count(*) from moved)::int as affected,
|
|
46
|
-
(select count(*) from
|
|
52
|
+
(select count(*) from eligible e
|
|
53
|
+
where not exists (
|
|
54
|
+
select 1 from candidates c
|
|
55
|
+
join moved m on m.id = c.id
|
|
56
|
+
where c.name = e.name and c.tenant = e.tenant
|
|
57
|
+
and c.idempotency_key = e.idempotency_key
|
|
58
|
+
))::int as remaining
|
|
47
59
|
`.trim();
|
|
48
60
|
|
|
49
61
|
/** Delete up to $4 rows and their step records. `running` is refused before this is sent. */
|
|
@@ -267,17 +279,25 @@ select task_name,
|
|
|
267
279
|
* `woken` announces each queue that received a job, once, when the statement commits — read in
|
|
268
280
|
* the final select because a plain-select CTE nothing reads is never run.
|
|
269
281
|
*
|
|
270
|
-
*
|
|
282
|
+
* The fence is the OCCURRENCE and the watermark may land past it: `run-once` runs the earliest
|
|
283
|
+
* missed occurrence and drops the rest, and dropping is the watermark moving beyond them. That was
|
|
284
|
+
* a second statement behind this one, and a crash between the two fired a second "one catch-up".
|
|
285
|
+
* `greatest` ignores a null, so with no `$4` the watermark lands on the occurrence — and a `$4`
|
|
286
|
+
* behind the occurrence can never leave the watermark short of what just fired.
|
|
287
|
+
*
|
|
288
|
+
* $1 task, $2 occurrence (ms), $3 the jobs as a JSON array, $4 where the watermark lands (ms) or
|
|
289
|
+
* null for the occurrence itself.
|
|
271
290
|
*/
|
|
272
291
|
export const SQL_SCHEDULER_FIRE = `
|
|
273
292
|
with moved as (
|
|
274
293
|
insert into x_scheduler_state
|
|
275
294
|
(task_name, last_fired_at, updated_at, fired_occurrence_at, fired_at)
|
|
276
|
-
values ($1, to_timestamp($2::bigint
|
|
295
|
+
values ($1, to_timestamp(greatest($2::bigint, $4::bigint) / 1000.0), now(),
|
|
296
|
+
to_timestamp($2::bigint / 1000.0), now())
|
|
277
297
|
on conflict (task_name) do update
|
|
278
298
|
set last_fired_at = excluded.last_fired_at, updated_at = now(),
|
|
279
299
|
fired_occurrence_at = excluded.fired_occurrence_at, fired_at = excluded.fired_at
|
|
280
|
-
where x_scheduler_state.last_fired_at < excluded.
|
|
300
|
+
where x_scheduler_state.last_fired_at < excluded.fired_occurrence_at
|
|
281
301
|
returning task_name
|
|
282
302
|
), queued as (
|
|
283
303
|
insert into x_jobs
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// that file is the claim/settle driver and sits near its size ceiling; this is the read-and-repair
|
|
4
4
|
// half a dashboard and `x jobs` call.
|
|
5
5
|
|
|
6
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
6
7
|
import { assert, finiteOption } from '@ultimat3/core';
|
|
7
8
|
import { REQUEUEABLE_STATES } from './driver';
|
|
8
|
-
import type { PgExecutor } from './driver-pg';
|
|
9
9
|
import { SQL_JOB_LIST, SQL_JOB_LIST_BEFORE } from './driver-pg-jobs-sql';
|
|
10
10
|
import {
|
|
11
11
|
SQL_COUNTER_DROP,
|
|
@@ -126,7 +126,14 @@ export function pgOperator(
|
|
|
126
126
|
);
|
|
127
127
|
const limit = pageLimit('the pg driver list', filter.limit);
|
|
128
128
|
const raw = filter.before ?? filter.after;
|
|
129
|
-
const cursor =
|
|
129
|
+
const cursor =
|
|
130
|
+
raw === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: parseJobCursor(
|
|
133
|
+
raw,
|
|
134
|
+
'the pg driver list',
|
|
135
|
+
filter.before === undefined ? 'after' : 'before',
|
|
136
|
+
);
|
|
130
137
|
const rows = await exec().query<JobRow>(
|
|
131
138
|
filter.before === undefined ? SQL_JOB_LIST : SQL_JOB_LIST_BEFORE,
|
|
132
139
|
[
|
|
@@ -162,7 +169,16 @@ export function pgOperator(
|
|
|
162
169
|
`requeueMany was asked for ${filter.state} jobs, and only a finished job can be requeued`,
|
|
163
170
|
"pass state: 'dead', 'failed', 'cancelled' or 'done' to requeueMany",
|
|
164
171
|
);
|
|
165
|
-
|
|
172
|
+
// Its own read, not `bulk`: the statement answers `remaining` itself — the rows a second
|
|
173
|
+
// call could move — where `matching - affected` counted the held rows it never will.
|
|
174
|
+
const rows = await exec().query<{ affected: Count; remaining: Count }>(SQL_JOB_REQUEUE_MANY, [
|
|
175
|
+
filter.state,
|
|
176
|
+
filter.queue ?? null,
|
|
177
|
+
filter.name ?? null,
|
|
178
|
+
MAX_BULK_ROWS,
|
|
179
|
+
filter.tenantId ?? null,
|
|
180
|
+
]);
|
|
181
|
+
const result = { affected: num(rows[0]?.affected), remaining: num(rows[0]?.remaining) };
|
|
166
182
|
if (result.affected > 0) await wake(filter.queue);
|
|
167
183
|
return result;
|
|
168
184
|
},
|
package/src/driver-pg-rows.ts
CHANGED
|
@@ -94,7 +94,7 @@ export function toJobRecord(row: JobRow): JobRecord {
|
|
|
94
94
|
attempt: row.attempt,
|
|
95
95
|
maxAttempts: row.max_attempts,
|
|
96
96
|
state: statusIn<JobState>(JOB_STATES, isJobState, {
|
|
97
|
-
table: '
|
|
97
|
+
table: 'x_jobs',
|
|
98
98
|
column: 'state',
|
|
99
99
|
value: row.state,
|
|
100
100
|
}),
|
|
@@ -146,7 +146,7 @@ export function toStepRecord(row: StepRow): StepRecord {
|
|
|
146
146
|
runId: row.run_id,
|
|
147
147
|
name: row.name,
|
|
148
148
|
status: statusIn<StepStatus>(STEP_STATUSES, isStepStatus, {
|
|
149
|
-
table: '
|
|
149
|
+
table: 'x_job_steps',
|
|
150
150
|
column: 'status',
|
|
151
151
|
value: row.status,
|
|
152
152
|
}),
|
|
@@ -168,7 +168,7 @@ export function toBackfillRun(row: BackfillRow): BackfillRun {
|
|
|
168
168
|
name: row.name,
|
|
169
169
|
checksum: row.checksum,
|
|
170
170
|
status: statusIn(BACKFILL_STATUSES, isBackfillStatus, {
|
|
171
|
-
table: '
|
|
171
|
+
table: 'x_backfills',
|
|
172
172
|
column: 'status',
|
|
173
173
|
value: row.status,
|
|
174
174
|
}),
|
|
@@ -17,8 +17,7 @@ const BUCKET = `${COUNTER_BUCKET_MS}, date_trunc('minute', now())`;
|
|
|
17
17
|
* that claimed it again is the SAME one: its id did not change, only the claim's ordinal did.
|
|
18
18
|
* `returning` is how the caller learns whether its settle landed.
|
|
19
19
|
*
|
|
20
|
-
* $1 id, $2 worker, $3 the attempt's duration in ms, $4 the claim
|
|
21
|
-
* row `x jobs drain` moved is `done` here and is not a completed run.
|
|
20
|
+
* $1 id, $2 worker, $3 the attempt's duration in ms, $4 the claim.
|
|
22
21
|
*/
|
|
23
22
|
export const SQL_ACK = `
|
|
24
23
|
with settled as (
|
|
@@ -28,7 +27,7 @@ with settled as (
|
|
|
28
27
|
returning name
|
|
29
28
|
), counted as (
|
|
30
29
|
insert into x_job_counters (job, bucket_ms, bucket_start, done, duration_ms)
|
|
31
|
-
select name, ${BUCKET}, 1, $3::bigint from settled
|
|
30
|
+
select name, ${BUCKET}, 1, $3::bigint from settled
|
|
32
31
|
on conflict (job, bucket_ms, bucket_start) do update
|
|
33
32
|
set done = x_job_counters.done + 1,
|
|
34
33
|
duration_ms = x_job_counters.duration_ms + excluded.duration_ms
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -19,7 +19,6 @@ export {
|
|
|
19
19
|
SQL_JOB_LIST,
|
|
20
20
|
SQL_JOB_LIVE_HOLDER,
|
|
21
21
|
SQL_JOB_REQUEUE,
|
|
22
|
-
SQL_STEPS_FROM,
|
|
23
22
|
} from './driver-pg-jobs-sql';
|
|
24
23
|
|
|
25
24
|
export {
|
|
@@ -32,8 +31,23 @@ export {
|
|
|
32
31
|
} from './driver-pg-outbox-sql';
|
|
33
32
|
export { SQL_ACK, SQL_NACK } from './driver-pg-settle-sql';
|
|
34
33
|
|
|
34
|
+
import { LEASE_LAPSED_FINAL_ATTEMPT } from './driver';
|
|
35
35
|
import { JOB_ROW_COLUMNS } from './driver-pg-jobs-sql';
|
|
36
36
|
import { notifyJobReady } from './driver-pg-wake-sql';
|
|
37
|
+
import { COUNTER_BUCKET_MS } from './introspection';
|
|
38
|
+
|
|
39
|
+
/** What both arms of the claim return: a `JobRow`, read off the updated row `j`. */
|
|
40
|
+
const CLAIM_ROW_COLUMNS = `j.id, j.name, j.queue, j.input, j.idempotency_key, j.run_id, j.attempt,
|
|
41
|
+
j.max_attempts, j.state, j.tenant_id, j.last_error, j.claimed_by, j.claims,
|
|
42
|
+
j.traceparent, j.enqueued_by,
|
|
43
|
+
(extract(epoch from j.run_at) * 1000)::bigint as run_at,
|
|
44
|
+
(extract(epoch from j.visible_at) * 1000)::bigint as visible_at,
|
|
45
|
+
(extract(epoch from j.created_at) * 1000)::bigint as created_at,
|
|
46
|
+
(extract(epoch from j.updated_at) * 1000)::bigint as updated_at`;
|
|
47
|
+
|
|
48
|
+
const CLAIM_ROW_NAMES = `id, name, queue, input, idempotency_key, run_id, attempt, max_attempts,
|
|
49
|
+
state, tenant_id, last_error, claimed_by, claims, traceparent, enqueued_by, run_at,
|
|
50
|
+
visible_at, created_at, updated_at`;
|
|
37
51
|
|
|
38
52
|
/**
|
|
39
53
|
* The insert, and its wake: `woke` is the notification's own column, evaluated once per inserted
|
|
@@ -85,10 +99,19 @@ select id, run_id from x_jobs
|
|
|
85
99
|
* lock and throughput collapses to one worker. `visible_at` in the predicate reclaims leases
|
|
86
100
|
* abandoned by a crashed worker. `cancelled` is absent from every branch by construction — a
|
|
87
101
|
* cancelled row is terminal, so it is never handed to a worker again.
|
|
102
|
+
*
|
|
103
|
+
* TWO arms over one pick. A `running` row is picked only with a lapsed lease, and one that lapsed
|
|
104
|
+
* on its FINAL attempt (`attempt >= max_attempts` — `isFinalAttempt`, `retry.ts`) is `buried`:
|
|
105
|
+
* settled `dead` here, counted, and answered with its new state so the driver reports it instead
|
|
106
|
+
* of handing it out. Re-claimed, it was an attempt past `max_attempts` every lease, forever — a
|
|
107
|
+
* job that kills its worker took one per visibility timeout and never reached the dead letters.
|
|
108
|
+
* `claimed_by` is cleared with the lease, so the body that stalled rather than died cannot settle
|
|
109
|
+
* the row it lost. A buried row spends a slot of `$2`: the pass after it claims what it displaced.
|
|
110
|
+
* `$5` names the jobs declaring `retry.deadLetter: false`: their rows are buried `failed`.
|
|
88
111
|
*/
|
|
89
112
|
export const SQL_CLAIM = `
|
|
90
|
-
with
|
|
91
|
-
select id
|
|
113
|
+
with picked as (
|
|
114
|
+
select id, (state = 'running' and attempt >= max_attempts) as exhausted
|
|
92
115
|
from x_jobs
|
|
93
116
|
where queue = any($1::text[])
|
|
94
117
|
and run_at <= now()
|
|
@@ -102,35 +125,56 @@ with claimed as (
|
|
|
102
125
|
order by run_at
|
|
103
126
|
limit $2
|
|
104
127
|
for update skip locked
|
|
128
|
+
), buried as (
|
|
129
|
+
update x_jobs j
|
|
130
|
+
set state = case when j.name = any($5::text[]) then 'failed' else 'dead' end,
|
|
131
|
+
visible_at = null,
|
|
132
|
+
claimed_by = null,
|
|
133
|
+
last_error = '${LEASE_LAPSED_FINAL_ATTEMPT}',
|
|
134
|
+
last_error_stack = null,
|
|
135
|
+
updated_at = now()
|
|
136
|
+
from picked p
|
|
137
|
+
where j.id = p.id and p.exhausted
|
|
138
|
+
returning ${CLAIM_ROW_COLUMNS}
|
|
139
|
+
), counted as (
|
|
140
|
+
insert into x_job_counters (job, bucket_ms, bucket_start, dead, failed, duration_ms)
|
|
141
|
+
select name, ${COUNTER_BUCKET_MS}, date_trunc('minute', now()),
|
|
142
|
+
(count(*) filter (where state = 'dead'))::int,
|
|
143
|
+
(count(*) filter (where state = 'failed'))::int, 0
|
|
144
|
+
from buried
|
|
145
|
+
group by name
|
|
146
|
+
on conflict (job, bucket_ms, bucket_start) do update
|
|
147
|
+
set dead = x_job_counters.dead + excluded.dead,
|
|
148
|
+
failed = x_job_counters.failed + excluded.failed
|
|
149
|
+
), claimed as (
|
|
150
|
+
update x_jobs j
|
|
151
|
+
set state = 'running',
|
|
152
|
+
attempt = j.attempt + 1,
|
|
153
|
+
claims = j.claims + 1,
|
|
154
|
+
claimed_by = $3,
|
|
155
|
+
visible_at = now() + ($4::bigint * interval '1 millisecond'),
|
|
156
|
+
updated_at = now()
|
|
157
|
+
from picked p
|
|
158
|
+
where j.id = p.id and not p.exhausted
|
|
159
|
+
returning ${CLAIM_ROW_COLUMNS}
|
|
105
160
|
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
claims = j.claims + 1,
|
|
110
|
-
claimed_by = $3,
|
|
111
|
-
visible_at = now() + ($4::bigint * interval '1 millisecond'),
|
|
112
|
-
updated_at = now()
|
|
113
|
-
from claimed c
|
|
114
|
-
where j.id = c.id
|
|
115
|
-
returning j.id, j.name, j.queue, j.input, j.idempotency_key, j.run_id, j.attempt,
|
|
116
|
-
j.max_attempts, j.state, j.tenant_id, j.last_error, j.claimed_by, j.claims,
|
|
117
|
-
j.traceparent, j.enqueued_by,
|
|
118
|
-
(extract(epoch from j.run_at) * 1000)::bigint as run_at,
|
|
119
|
-
(extract(epoch from j.visible_at) * 1000)::bigint as visible_at,
|
|
120
|
-
(extract(epoch from j.created_at) * 1000)::bigint as created_at,
|
|
121
|
-
(extract(epoch from j.updated_at) * 1000)::bigint as updated_at
|
|
161
|
+
select ${CLAIM_ROW_NAMES} from claimed
|
|
162
|
+
union all
|
|
163
|
+
select ${CLAIM_ROW_NAMES} from buried
|
|
122
164
|
`.trim();
|
|
123
165
|
|
|
124
166
|
/**
|
|
125
|
-
* Stop a job from outside
|
|
126
|
-
*
|
|
127
|
-
*
|
|
167
|
+
* Stop a job from outside — a LIVE one. The runaway backfill this exists for is `running`; the
|
|
168
|
+
* worker holding it learns on its next heartbeat, which no longer matches `state = 'running'`.
|
|
169
|
+
* Fenced on the four live states and not on `state <> 'done'`, which let a cancel REWRITE a dead
|
|
170
|
+
* letter: `dead / "card declined"` became `cancelled / "oops wrong id"` and left the dead-letter
|
|
171
|
+
* queue. A finished row is how the job ended, and stays that.
|
|
128
172
|
*/
|
|
129
173
|
export const SQL_CANCEL = `
|
|
130
174
|
update x_jobs
|
|
131
175
|
set state = 'cancelled', visible_at = null, claimed_by = null,
|
|
132
176
|
last_error = coalesce($2, last_error), updated_at = now()
|
|
133
|
-
where id = $1 and state
|
|
177
|
+
where id = $1 and state in ('ready', 'delayed', 'running', 'suspended')
|
|
134
178
|
returning ${JOB_ROW_COLUMNS}
|
|
135
179
|
`.trim();
|
|
136
180
|
|
|
@@ -167,12 +211,12 @@ select queue,
|
|
|
167
211
|
(where state in ('ready', 'delayed') and run_at <= now()), 0) * 1000 as oldest_ready_ms
|
|
168
212
|
from x_jobs
|
|
169
213
|
group by queue
|
|
170
|
-
order by queue
|
|
214
|
+
order by queue collate "C"
|
|
171
215
|
`.trim();
|
|
172
216
|
|
|
173
217
|
/**
|
|
174
218
|
* Session-scoped, so a crashed node's lock releases itself — and released just as surely when a
|
|
175
|
-
* POOLED connection goes back to the pool, which is why `
|
|
219
|
+
* POOLED connection goes back to the pool, which is why `postgresLeader` is not what a scheduler
|
|
176
220
|
* running on a shared executor should use. See `SQL_LEADER_ACQUIRE` below.
|
|
177
221
|
*/
|
|
178
222
|
export const SQL_TRY_ADVISORY_LOCK = 'select pg_try_advisory_lock($1) as locked';
|
|
@@ -324,7 +368,17 @@ select id, name, payload, correlation_key,
|
|
|
324
368
|
limit $2
|
|
325
369
|
`.trim();
|
|
326
370
|
|
|
327
|
-
|
|
371
|
+
/**
|
|
372
|
+
* The sweep, COUNTED in the statement: `PgExecutor` answers rows and never a command tag, and
|
|
373
|
+
* `returning` alone would ship every deleted id back to count them. The statement's own time, as
|
|
374
|
+
* every other event statement reads — an unpurged row costs `find` a filter, never a wrong answer.
|
|
375
|
+
*/
|
|
376
|
+
export const SQL_EVENT_PURGE_COUNTED = `
|
|
377
|
+
with gone as (
|
|
378
|
+
delete from x_job_events where expires_at <= statement_timestamp() returning 1
|
|
379
|
+
)
|
|
380
|
+
select count(*)::int as removed from gone
|
|
381
|
+
`.trim();
|
|
328
382
|
|
|
329
383
|
export const SQL_STEP_GET = `
|
|
330
384
|
select run_id, name, status, output, attempts, error,
|
|
@@ -351,18 +405,33 @@ select run_id, name, status, output, attempts, error,
|
|
|
351
405
|
from x_job_steps where run_id = $1 order by started_at
|
|
352
406
|
`.trim();
|
|
353
407
|
|
|
408
|
+
/**
|
|
409
|
+
* FENCED on the claim when one is given, exactly as `SQL_ACK` is: `running`, the same worker, the
|
|
410
|
+
* same claim ordinal. The runner's own fence is an in-process signal, and it cannot stop a body in
|
|
411
|
+
* ANOTHER process — a worker stalled past its lease wrote its step over the re-claimer's. A null
|
|
412
|
+
* `$12` is an unfenced write: a transfer between drivers, with no row on this one to hold yet.
|
|
413
|
+
* Answers one row when the write landed, none when the fence refused it.
|
|
414
|
+
*
|
|
415
|
+
* $12 the job id or null, $13 the worker, $14 the claim.
|
|
416
|
+
*/
|
|
354
417
|
export const SQL_STEP_PUT = `
|
|
355
418
|
insert into x_job_steps
|
|
356
419
|
(run_id, name, status, output, started_at, completed_at, wake_at, event,
|
|
357
420
|
correlation_key, attempts, error)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
421
|
+
select $1::uuid, $2, $3, $4::jsonb, to_timestamp($5::bigint / 1000.0),
|
|
422
|
+
case when $6::bigint is null then null else to_timestamp($6::bigint / 1000.0) end,
|
|
423
|
+
case when $7::bigint is null then null else to_timestamp($7::bigint / 1000.0) end,
|
|
424
|
+
$8, $9, $10::int, $11
|
|
425
|
+
where $12::uuid is null
|
|
426
|
+
or exists (
|
|
427
|
+
select 1 from x_jobs
|
|
428
|
+
where id = $12::uuid and state = 'running' and claimed_by = $13 and claims = $14::int
|
|
429
|
+
)
|
|
362
430
|
on conflict (run_id, name) do update
|
|
363
431
|
set status = excluded.status, output = excluded.output,
|
|
364
432
|
completed_at = excluded.completed_at, wake_at = excluded.wake_at,
|
|
365
433
|
attempts = excluded.attempts, error = excluded.error
|
|
434
|
+
returning 1 as written
|
|
366
435
|
`.trim();
|
|
367
436
|
|
|
368
437
|
/**
|