@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.
Files changed (70) hide show
  1. package/CLAUDE.md +72 -75
  2. package/README.md +83 -38
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +25 -3
  6. package/src/backfill-pass.ts +22 -5
  7. package/src/backfill-rate.ts +2 -2
  8. package/src/backfill.ts +2 -2
  9. package/src/claim-exhausted.ts +76 -0
  10. package/src/counters.ts +1 -1
  11. package/src/driver-memory-operator.ts +34 -12
  12. package/src/driver-memory.ts +80 -38
  13. package/src/driver-pg-jobs-sql.ts +27 -19
  14. package/src/driver-pg-operator-sql.ts +32 -12
  15. package/src/driver-pg-operator.ts +19 -3
  16. package/src/driver-pg-rows.ts +3 -3
  17. package/src/driver-pg-settle-sql.ts +2 -3
  18. package/src/driver-pg-sql.ts +99 -30
  19. package/src/driver-pg.ts +103 -98
  20. package/src/driver.ts +46 -16
  21. package/src/errors-concurrency.ts +1 -1
  22. package/src/errors-operator.ts +12 -4
  23. package/src/errors-requeue.ts +21 -3
  24. package/src/errors.ts +9 -17
  25. package/src/events-pg.ts +11 -21
  26. package/src/events.ts +26 -15
  27. package/src/execute.ts +47 -5
  28. package/src/export.ts +2 -2
  29. package/src/heartbeat.ts +7 -2
  30. package/src/index.ts +45 -99
  31. package/src/inspect-operator.ts +4 -4
  32. package/src/inspect.ts +5 -4
  33. package/src/introspection.ts +23 -11
  34. package/src/job.ts +6 -7
  35. package/src/leases.ts +1 -1
  36. package/src/limits.ts +46 -15
  37. package/src/outbox-lease.ts +1 -1
  38. package/src/outbox-pg.ts +6 -7
  39. package/src/outbox-relay.ts +6 -6
  40. package/src/outbox.ts +47 -19
  41. package/src/purge.ts +1 -1
  42. package/src/queue-wake.ts +1 -2
  43. package/src/renewal-timer.ts +1 -1
  44. package/src/retry-classification.ts +52 -15
  45. package/src/scheduler-leader.ts +2 -2
  46. package/src/scheduler-pg.ts +19 -16
  47. package/src/scheduler-state.ts +10 -3
  48. package/src/scheduler.ts +14 -15
  49. package/src/steps-memory.ts +2 -2
  50. package/src/steps-suspension.ts +46 -0
  51. package/src/steps-timeout.ts +33 -0
  52. package/src/steps.ts +90 -88
  53. package/src/task.ts +1 -1
  54. package/src/webhook-attempt.ts +206 -0
  55. package/src/webhook-ledger.ts +15 -4
  56. package/src/webhook.ts +64 -118
  57. package/src/worker-admit.ts +14 -6
  58. package/src/worker-drain-cutoff.ts +110 -0
  59. package/src/worker-fleet-slots.ts +82 -45
  60. package/src/worker-held.ts +100 -0
  61. package/src/worker-key-busy.ts +38 -10
  62. package/src/worker-loop.ts +35 -11
  63. package/src/worker-options.ts +19 -15
  64. package/src/worker-registry.ts +16 -1
  65. package/src/worker-run.ts +38 -8
  66. package/src/worker-tally.ts +53 -0
  67. package/src/worker-types.ts +7 -4
  68. package/src/worker.ts +124 -73
  69. package/src/driver-nats.ts +0 -86
  70. 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
- update x_jobs
110
- set state = 'ready', attempt = 0, run_at = now(), updated_at = now(),
111
- claimed_by = null, visible_at = null
112
- where id = $1 and state in ('dead', 'cancelled', 'done', 'failed')
113
- returning ${JOB_ROW_COLUMNS},
114
- pg_notify('${JOBS_WAKE_CHANNEL}', queue)::text as woke
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. `distinct on` the idempotency namespace, because two dead rows
19
- * of one key would both go `ready` and the second would break the live index — and `not exists`
20
- * a live holder, the single requeue's `X_JOB_DUPLICATE`, answered here as "not this row".
21
- * `matching` is counted in the same statement, before the update, so the caller's `remaining` is
22
- * `matching - affected` with no second read.
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 candidates as (
26
- select distinct on (j.name, coalesce(j.tenant_id, ''), j.idempotency_key) j.id
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
- order by j.name, coalesce(j.tenant_id, ''), j.idempotency_key, j.created_at
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 x_jobs where ${bulkMatch()})::int as matching
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
- * $1 task, $2 occurrence (ms), $3 the jobs as a JSON array.
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 / 1000.0), now(), to_timestamp($2::bigint / 1000.0), now())
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.last_fired_at
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 = raw === undefined ? undefined : parseJobCursor(raw, 'the pg driver list');
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
- const result = await bulk(SQL_JOB_REQUEUE_MANY, filter);
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
  },
@@ -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: 'ultimate_jobs',
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: 'ultimate_job_steps',
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: 'ultimate_backfills',
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, $5 whether it is counted — a
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 where $5::boolean
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
@@ -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 claimed as (
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
- update x_jobs j
107
- set state = 'running',
108
- attempt = j.attempt + 1,
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. `state <> 'done'` and not `state = 'ready'`: the runaway backfill this
126
- * exists for is `running`, and a job that already finished has nothing to stop. The worker holding
127
- * it learns on its next heartbeat, which no longer matches `state = 'running'`.
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 <> 'done'
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 `createPgLeader` is not what a scheduler
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
- export const SQL_EVENT_PURGE = `delete from x_job_events where expires_at <= statement_timestamp()`;
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
- values ($1, $2, $3, $4::jsonb, to_timestamp($5 / 1000.0),
359
- case when $6::bigint is null then null else to_timestamp($6 / 1000.0) end,
360
- case when $7::bigint is null then null else to_timestamp($7 / 1000.0) end,
361
- $8, $9, $10, $11)
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
  /**