@ultimat3/jobs 23.0.0 → 24.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 (45) hide show
  1. package/CLAUDE.md +51 -52
  2. package/README.md +34 -5
  3. package/package.json +6 -6
  4. package/src/backfill-ledger.ts +24 -2
  5. package/src/backfill-pass.ts +22 -5
  6. package/src/claim-exhausted.ts +76 -0
  7. package/src/driver-memory-operator.ts +31 -9
  8. package/src/driver-memory.ts +68 -23
  9. package/src/driver-nats.ts +5 -2
  10. package/src/driver-pg-jobs-sql.ts +27 -19
  11. package/src/driver-pg-operator-sql.ts +32 -12
  12. package/src/driver-pg-operator.ts +19 -3
  13. package/src/driver-pg-rows.ts +3 -3
  14. package/src/driver-pg-sql.ts +98 -29
  15. package/src/driver-pg.ts +86 -81
  16. package/src/driver-redis.ts +5 -2
  17. package/src/driver.ts +37 -1
  18. package/src/errors-operator.ts +12 -4
  19. package/src/errors-requeue.ts +21 -3
  20. package/src/errors.ts +5 -13
  21. package/src/events-pg.ts +8 -18
  22. package/src/events.ts +20 -9
  23. package/src/execute.ts +1 -0
  24. package/src/index.ts +10 -4
  25. package/src/inspect-operator.ts +3 -3
  26. package/src/inspect.ts +4 -3
  27. package/src/introspection.ts +23 -11
  28. package/src/job.ts +5 -6
  29. package/src/limits.ts +33 -7
  30. package/src/outbox-pg.ts +1 -2
  31. package/src/outbox.ts +37 -9
  32. package/src/purge.ts +1 -1
  33. package/src/queue-wake.ts +1 -2
  34. package/src/scheduler-pg.ts +3 -2
  35. package/src/scheduler-state.ts +8 -1
  36. package/src/scheduler.ts +9 -10
  37. package/src/steps-suspension.ts +46 -0
  38. package/src/steps-timeout.ts +33 -0
  39. package/src/steps.ts +55 -81
  40. package/src/worker-admit.ts +7 -3
  41. package/src/worker-fleet-slots.ts +78 -44
  42. package/src/worker-key-busy.ts +38 -10
  43. package/src/worker-loop.ts +35 -11
  44. package/src/worker-registry.ts +16 -1
  45. package/src/worker.ts +52 -9
@@ -3,7 +3,7 @@
3
3
  // real claim/ack/nack paths rather than a mock that always succeeds.
4
4
 
5
5
  import type { Clock } from '@ultimat3/core';
6
- import { assert, finiteCount, systemClock, uuid } from '@ultimat3/core';
6
+ import { finiteCount, systemClock, uuid } from '@ultimat3/core';
7
7
  import type { BackfillLedger } from './backfill-ledger';
8
8
  import { createMemoryBackfillLedger } from './backfill-ledger';
9
9
  import { nowMs } from './clock';
@@ -25,17 +25,20 @@ import {
25
25
  assertClaimBounds,
26
26
  assertClaimQueues,
27
27
  DEFAULT_QUEUE,
28
+ LEASE_LAPSED_FINAL_ATTEMPT,
29
+ LIVE_STATES,
28
30
  nackState,
29
31
  REQUEUEABLE_STATES,
30
32
  } from './driver';
31
33
  import { createMemoryOperator } from './driver-memory-operator';
32
34
  import { signalEnqueued } from './enqueue-signal';
33
- import { JobDuplicateError } from './errors';
34
- import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
35
+ import { JobDuplicateError, LeaseLostError } from './errors';
36
+ import { JobNotFoundError, JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
35
37
  import type { JobIntrospection } from './introspection';
36
38
  import { MAX_ERROR_STACK_LENGTH } from './introspection';
37
39
  import type { LeaseStore } from './leases';
38
40
  import { createMemoryLeaseStore } from './leases';
41
+ import { isFinalAttempt } from './retry';
39
42
  import type { StepStore } from './steps';
40
43
  import { createMemoryStepStore } from './steps-memory';
41
44
 
@@ -48,8 +51,6 @@ export interface MemoryDriverOptions {
48
51
  readonly leases?: LeaseStore;
49
52
  }
50
53
 
51
- const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
52
-
53
54
  /**
54
55
  * The in-memory driver's own type: `JobDriver` with `close` REQUIRED.
55
56
  *
@@ -63,7 +64,21 @@ export type MemoryJobDriver = JobDriver & { close(): Promise<void> };
63
64
 
64
65
  export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJobDriver {
65
66
  const clock = options.clock ?? systemClock;
66
- const steps = options.steps ?? createMemoryStepStore();
67
+ const stepStore = options.steps ?? createMemoryStepStore();
68
+ // `SQL_STEP_PUT`'s fence: a write made under a claim lands only while that claim holds the row.
69
+ const steps: StepStore = {
70
+ ...stepStore,
71
+ put(record, by) {
72
+ const row = by === undefined ? undefined : jobs.get(by.jobId);
73
+ if (
74
+ by !== undefined &&
75
+ !(row?.state === 'running' && row.claimedBy === by.workerId && row.claim === by.claim)
76
+ ) {
77
+ return Promise.reject(new LeaseLostError({ job: by.job, jobId: by.jobId }));
78
+ }
79
+ return stepStore.put(record);
80
+ },
81
+ };
67
82
  const backfills = options.backfills ?? createMemoryBackfillLedger(clock);
68
83
  const leases =
69
84
  options.leases ?? createMemoryLeaseStore(options.clock === undefined ? {} : { clock });
@@ -104,11 +119,19 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
104
119
  * lease deadline — which `x jobs show` prints, and which is the very pair the claim scan's
105
120
  * lease-expiry branch reads to decide a row was abandoned.
106
121
  */
107
- const settle = (id: string, patch: Partial<JobRecord>): void => {
122
+ const settle = (id: string, patch: Partial<JobRecord>, drop?: 'stack'): void => {
108
123
  const existing = jobs.get(id);
109
124
  if (existing === undefined) return;
110
125
  const { visibleAt: _visibleAt, claimedBy: _claimedBy, ...released } = existing;
111
- jobs.set(id, { ...released, ...patch, updatedAt: nowMs(clock) });
126
+ // `drop: 'stack'` is `SQL_NACK`'s `case when $5 is null then last_error_stack else $7 end`
127
+ // with no stack bound: a NEW failure replaces the old one's stack even with nothing — the key
128
+ // is removed, never set to `undefined`, as a null column is absent from a pg record.
129
+ const { lastErrorStack: _stack, ...unstacked } = released;
130
+ jobs.set(id, {
131
+ ...(drop === 'stack' ? unstacked : released),
132
+ ...patch,
133
+ updatedAt: nowMs(clock),
134
+ });
112
135
  };
113
136
 
114
137
  const operator = createMemoryOperator({
@@ -145,13 +168,9 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
145
168
  return rows;
146
169
  },
147
170
  async requeue(jobId, requeueOptions) {
148
- const existing = jobs.get(jobId);
149
- assert(
150
- existing !== undefined,
151
- `no job ${jobId} in the memory driver`,
152
- 'requeue a job id returned by enqueue() or stats() — the memory driver holds no state across processes, so an id from another run will not resolve',
153
- );
154
- const record = existing;
171
+ const record = jobs.get(jobId);
172
+ // The one answer both drivers give an id nobody queued — it was `X_INVARIANT` here.
173
+ if (record === undefined) throw new JobNotFoundError({ jobId, driver: 'memory' });
155
174
  // The two refusals `SQL_JOB_REQUEUE` and `SQL_JOB_LIVE_HOLDER` give, before anything moves.
156
175
  if (!REQUEUEABLE_STATES.has(record.state)) {
157
176
  throw new JobNotRequeueableError({ jobId, state: record.state });
@@ -161,7 +180,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
161
180
  throw requeueKeyTaken({ ...record, holderId: holder.id });
162
181
  }
163
182
  if (requeueOptions?.fromStep !== undefined) {
164
- // The target step and every step that started AFTER it — `SQL_STEPS_FROM`, whose header
183
+ // The target step and every step that started AFTER it — `SQL_JOB_REQUEUE`, whose header
165
184
  // says why a tie is kept. Earlier steps stay memoized; a later one replaying the old run's
166
185
  // result is what this prevents.
167
186
  const all = await steps.list(record.runId);
@@ -182,9 +201,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
182
201
  },
183
202
  cancel(jobId, reason) {
184
203
  const existing = jobs.get(jobId);
185
- // `state !== 'done'`, mirroring `SQL_CANCEL`: a job that already finished has nothing to
186
- // stop, and cancelling it would rewrite a terminal row an operator is reading as success.
187
- if (existing === undefined || existing.state === 'done') return Promise.resolve(undefined);
204
+ // LIVE rows only, mirroring `SQL_CANCEL`: a job that already finished has nothing to stop.
205
+ // The fence was `state !== 'done'`, which let a cancel REWRITE a dead letter — the record of
206
+ // a failure, and its `lastError`, became the record of an operator's typo.
207
+ if (existing === undefined || !LIVE_STATES.has(existing.state)) {
208
+ return Promise.resolve(undefined);
209
+ }
188
210
  // `settle`, not `update`: a cancellation RELEASES the claim, and `SQL_CANCEL` writes
189
211
  // `visible_at = null, claimed_by = null` with the state. Left stamped, a cancelled row named
190
212
  // the worker still holding it and carried that attempt's lease deadline — the pair
@@ -276,7 +298,24 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
276
298
  .slice(0, claimOptions.limit);
277
299
 
278
300
  const out: ClaimedJob[] = [];
301
+ const buried: JobRecord[] = [];
279
302
  for (const record of claimable) {
303
+ // `SQL_CLAIM`'s second arm. A `running` row reaches this loop only with a lapsed lease, and
304
+ // one that lapsed on its FINAL attempt has nothing left to be re-delivered for: handing
305
+ // it out was an attempt past `maxAttempts`, every lease, forever — a job that kills its
306
+ // worker took one per visibility timeout and never reached the dead-letter queue.
307
+ if (
308
+ record.state === 'running' &&
309
+ isFinalAttempt({ attempts: record.maxAttempts }, record.attempt)
310
+ ) {
311
+ // `$5` of the statement: a job declaring `retry.deadLetter: false` is dropped.
312
+ const state = claimOptions.dropExhausted?.includes(record.name) ? 'failed' : 'dead';
313
+ settle(record.id, { state, lastError: LEASE_LAPSED_FINAL_ATTEMPT }, 'stack');
314
+ operator.counters.add(record.name, state, 0, at);
315
+ const dead = jobs.get(record.id);
316
+ if (dead !== undefined) buried.push(dead);
317
+ continue;
318
+ }
280
319
  const claimed: ClaimedJob = {
281
320
  ...record,
282
321
  state: 'running',
@@ -290,7 +329,8 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
290
329
  jobs.set(record.id, claimed);
291
330
  out.push(claimed);
292
331
  }
293
- return Promise.resolve(out);
332
+ if (buried.length > 0) claimOptions.onExhausted?.(buried);
333
+ return out;
294
334
  },
295
335
 
296
336
  // Both settlements are FENCED on `running` AND on the claimer, as `SQL_ACK`/`SQL_NACK` are: an
@@ -323,11 +363,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
323
363
  // being read as the only one.
324
364
  attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
325
365
  ...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
326
- ...(nackOptions.stack === undefined
366
+ // The stack belongs to the ERROR beside it, as `SQL_NACK` binds it: no error, no stack.
367
+ ...(nackOptions.error === undefined || nackOptions.stack === undefined
327
368
  ? {}
328
369
  : { lastErrorStack: nackOptions.stack.slice(0, MAX_ERROR_STACK_LENGTH) }),
329
370
  };
330
- settle(jobId, patch);
371
+ settle(jobId, patch, nackOptions.error === undefined ? undefined : 'stack');
331
372
  const outcome = nackOutcome(nackOptions);
332
373
  if (outcome !== undefined) {
333
374
  operator.counters.add(record.name, outcome, nackOptions.durationMs ?? 0, at);
@@ -382,7 +423,11 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
382
423
  else if (record.state === 'dead') next.dead += 1;
383
424
  byQueue.set(record.queue, next);
384
425
  }
385
- return Promise.resolve([...byQueue.values()].sort((a, b) => a.queue.localeCompare(b.queue)));
426
+ // Code units, as `collate "C"` orders them in `SQL_STATS` — never `localeCompare`, which
427
+ // answers by the runtime's locale and so differently from the database on the same names.
428
+ return Promise.resolve(
429
+ [...byQueue.values()].sort((a, b) => (a.queue < b.queue ? -1 : a.queue > b.queue ? 1 : 0)),
430
+ );
386
431
  },
387
432
 
388
433
  close(): Promise<void> {
@@ -3,6 +3,7 @@
3
3
  // a durable pull consumer per worker pool (`fetch` == claim, `ack`/`nak` map 1:1),
4
4
  // `ack_wait` as the visibility timeout, and a KV bucket for step records.
5
5
 
6
+ import { NotImplementedError } from '@ultimat3/core';
6
7
  import type {
7
8
  ClaimedJob,
8
9
  ClaimOptions,
@@ -13,7 +14,6 @@ import type {
13
14
  NackOptions,
14
15
  QueueStats,
15
16
  } from './driver';
16
- import { JobsNotImplementedError } from './errors';
17
17
  import type { StepRecord, StepStore } from './steps';
18
18
 
19
19
  // Names the seam that actually replaces the stub, and NOTHING ELSE — the two other repairs this
@@ -34,7 +34,10 @@ const FIX =
34
34
  'call setJobDriver(createPgDriver()) at boot instead of this driver; nothing needs moving first, because enqueue here refuses too, so no job was ever written to it';
35
35
 
36
36
  const unavailable = (method: string): never => {
37
- throw new JobsNotImplementedError({ feature: `nats jobs driver (${method})`, fix: FIX });
37
+ throw new NotImplementedError({
38
+ cause: `nats jobs driver (${method}) is not implemented`,
39
+ fix: FIX,
40
+ });
38
41
  };
39
42
 
40
43
  const natsStepStore = (): StepStore => ({
@@ -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
  }),
@@ -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,7 +211,7 @@ 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
  /**
@@ -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
  /**