@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
package/CLAUDE.md CHANGED
@@ -8,7 +8,8 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
8
8
  `expectedQueryLoop` ONLY: `steps.ts` declares the per-step write one-per-step to the N+1
9
9
  detector there, and no client is ever taken from it. Never `http`, `render`, `ui`.
10
10
  - Consumers: `action` (`<job>.enqueue`, via the ambient jobs facade), `cli`, `mcp`, `admin`.
11
- - External deps: none. Postgres access goes through the injected `PgExecutor`.
11
+ - External deps: none. Postgres access goes through the injected `PgExecutor` (`@ultimat3/core`'s,
12
+ re-exported from `index.ts`).
12
13
 
13
14
  ## Rules — registration and declarations
14
15
 
@@ -29,7 +30,7 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
29
30
  - **JOBS DECLARE THEIR TENANT, and `executeJob` INSTALLS the context — do not re-litigate.**
30
31
  `tenant` is REQUIRED (`(input) => input.orgId` or `'none'`; `X_JOB_TENANT_REQUIRED` backstop).
31
32
  `executeJob` puts the derived org on the ctx's actor (`jobRunActor`, org only) and runs the body in
32
- `runWithContext`; `'none'` STRIPS the org (fail-closed). A boot-supplied service actor was rejected.
33
+ `runWithContext`; `'none'` STRIPS the org (fail-closed).
33
34
  `tenantFor` is a METHOD on `JobHandle` (variance). The queue row's `tenantId` stays the ENQUEUER's
34
35
  (the limiter's bucket). `tenancy-cross-surface.test.ts` asserts HTTP and job verdicts are EQUAL.
35
36
  - **`enqueuedBy` is ATTRIBUTION, never authority** — do not re-litigate. A job acting FOR a user takes
@@ -43,39 +44,37 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
43
44
 
44
45
  ## Rules — the driver and SQL
45
46
 
46
- - **`SQL_JOBS_TABLE` is the ONE install point** for every durable table (`x_jobs`, `x_job_steps`,
47
- `x_backfills`, `x_outbox`, `x_scheduler_state`, `x_scheduler_leader`, `x_job_leases`,
48
- `x_job_events`, `x_job_pauses`, `x_job_workers`, `x_job_counters`). A NEW table is also a name in
47
+ - **`SQL_JOBS_TABLE` is the ONE install point** for every durable table (`x_jobs`, `x_job_*`, `x_backfills`,
48
+ `x_outbox`, `x_scheduler_*`). A NEW table is also a name in
49
49
  `packages/cli/src/framework-schema.ts`. A shipped table grows by `alter table ... add column if
50
50
  not exists`. Its comments carry NO apostrophes and NO semicolons (`dev-queue.ts` splits on `;`; `driver-pg-sql.test.ts` checks
51
51
  quote parity).
52
52
  - **`claim({ queues: [] })` is REFUSED by every driver** (`assertClaimQueues`,
53
- `X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's `claim`/`list`/`deadLetters` are `async` so a
54
- refusal rejects on both.
53
+ `X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's reads are `async` so a refusal rejects on both.
55
54
  - **`ack`, `nack`, `heartbeat` and `recordProgress` are FENCED on `state = 'running'` AND the
56
55
  CLAIM** — `{ workerId, claim }`, `claimOf(claimed)`; `x_jobs.claims` is moved by the claim and
57
- never reset. A worker id alone let a worker's own earlier body settle its re-claim. `heartbeat`
58
- answers a boolean, read as `held === false` (never `!held`).
59
- - **A driver's semantics are pinned in ONE test beside the pg statement** (`driver-parity.test.ts`):
60
- list order, the `attempt` floor, `SQL_LEASE_RENEW` fencing on `expires_at > now()` and `holder`,
61
- `SQL_STATS` one bucket per row.
56
+ never reset. `heartbeat` answers a boolean, read as `held === false` (never `!held`).
57
+ - **A driver's semantics are pinned beside the pg statement** (`driver-parity*.test.ts`);
58
+ `driver-*-fixture.ts` scenarios run on memory AND Postgres.
59
+ - **The claim BURIES a lease that lapsed on the final attempt** (`attempt >= max_attempts`): `dead`
60
+ in `SQL_CLAIM` — `failed` for a name in `ClaimOptions.dropExhausted` (`retry.deadLetter: false`)
61
+ — counted, never returned; the worker's round hears `onExhausted` and calls `announceExhausted`.
62
+ - **`cancel` is fenced on `LIVE_STATES`.** **`requeue`** refuses (`X_JOB_NOT_FOUND`, not requeueable,
63
+ duplicate), then ONE statement. **`enqueue` inserts twice at most.** **`BulkResult.remaining` is
64
+ what a second call would move.**
62
65
  - **No read returns a WHOLE row** — `driver-pg-sql.test.ts` scans every production file here
63
66
  (discovered, comments stripped) for `select *`/`returning *`.
64
67
  - Drivers implement the six `JobDriver` methods plus optional `introspect`, `backfills`, `leases`. New
65
68
  capabilities go behind the interface. `inspect.ts` returns plain JSON for CLI, `/_x` and MCP.
66
- - Step results are persisted BEFORE the step returns. All time is epoch ms from an injected `Clock`
67
- (`nowMs()` in `clock.ts`).
69
+ - Step results are persisted BEFORE the step returns. All time is epoch ms (`nowMs()`, `clock.ts`).
68
70
  - **`createPgLeader` is correct only on a DEDICATED connection**; boot uses `createPgLeaseLeader`.
69
- - `driver-redis.ts` / `driver-nats.ts` are honest `X_NOT_IMPLEMENTED` stubs.
70
71
 
71
72
  ## Rules — numbers and limits
72
73
 
73
74
  - **Every numeric knob is refused when not FINITE** — core's `finiteOption()` (a bound) and
74
75
  `finiteCount()` (a count, caller's minimum); `worker-options.ts` is where `createWorker` reads them.
75
- **`bun run finite-bounds` is a floor, never proof**: `createLimiter`'s five numbers (`perTenant`,
76
- `perQueue`, `global`, `ratePerTenant.limit` / `.windowMs`) are screened with min 0 (zero is a HARD
77
- STOP). **A row count is `finiteCount` (min 0)** for driver parity (`list`, `deadLetters`, the
78
- backfill ledger's `list`, `assertClaimBounds`).
76
+ **`bun run finite-bounds` is a floor, never proof**: `createLimiter`'s five numbers are screened
77
+ with min 0 (zero is a HARD STOP). **A row count is `finiteCount` (min 0)**; `retry.attempts` is one with min 1.
79
78
  - **`WorkerOptions.concurrency` is read by OWN key** — a queue named `constructor`
80
79
  (`worker-slots.test.ts`).
81
80
  - **`job.concurrency` is enforced by `JobDriver.leases`** (one row per held slot in `x_job_leases`);
@@ -111,12 +110,11 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
111
110
  letter), read off the SIGNAL. `WorkerStats.interrupted`; `worker-drain-signal.test.ts`.
112
111
  - **The drain waits out the claim round it races** — `tick()` registers its round in `rounds`
113
112
  synchronously with its guard.
114
- - **A fleet slot is taken INSIDE a `try`, released AWAITED, and a renewal answering `false` CANCELS the
115
- run** (`X_JOB_SLOT_LOST`, read as `renewed !== false`).
113
+ - **A fleet slot is taken INSIDE a `try`, released AWAITED, and HELD, not owned**: `false`, or a TTL
114
+ with no renewal landing, CANCELS the run (`X_JOB_SLOT_LOST`); held per job id as a LIST.
116
115
  - **The run's signal is a controller this worker owns** (`run-signal.ts`), never `AbortSignal.any`.
117
- - **A lease is HELD, not owned** (`heartbeat.ts`): one failed renewal is `jobs.heartbeat.failed`; a
118
- whole window without one landing is `jobs.lease.lost` + `recordLeaseLost(queue)`, measured on this
119
- process's clock, asked both sides of the call.
116
+ - **A lease is HELD, not owned** (`heartbeat.ts`): one failed renewal warns; a whole window without
117
+ one landing is `jobs.lease.lost`, on this process's clock, asked both sides of the call.
120
118
  - **A renewal is decided against `stopped()`** (`renewal-timer.ts`, re-read after every await); the
121
119
  interval is `unref`ed; every renewal is armed through `WorkerOptions.schedule`.
122
120
  - **Settlement is not part of the retry decision**: `driver.ack` sits after the `try`.
@@ -125,15 +123,19 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
125
123
  - **The claim loop re-arms on the PASS, never the jobs**; `tick()` resolves with that pass's executions.
126
124
  - **A deadline CANCELS, then fails the attempt**, never the reverse (`raceTimeout`, `withStepTimeout`);
127
125
  the attempt is cancelled in `executeJob`'s `finally`.
128
- - **A cancelled runner writes NOTHING** — `put()` in `steps.ts` is the one fence (`X_ABORTED`);
129
- `jobs.timeout.abandoned` names an uncooperative body. `executeJob` reads `ctx.signal` defensively.
126
+ - **A cancelled runner writes NOTHING** — `put()` in `steps.ts` (`X_ABORTED`), and the STORE fences
127
+ the claim (`StepFence`, `SQL_STEP_PUT` as `SQL_ACK`: `X_JOB_LEASE_LOST`). `executeJob` passes it.
130
128
  - **`traceparent` is stamped at ENQUEUE time, in `outbox.ts`**, and an empty `spanId` sends none.
131
129
  - **The scheduler runs one dispatch round at a time**; every other `tick()` joins it; `stop()` waits it
132
130
  out before `leader.release()`. Same two-hook rule; an ABANDONED round does not release
133
131
  (`jobs.scheduler.drain-abandoned`). `stillLeading()` is asked every round and before every task,
134
132
  and answers from memory inside `renewEveryMs`.
135
- - **`run-once` fires ONE catch-up** (marks `at`); `skip` fires the true latest missed occurrence
136
- (`latestOccurrenceBy`, bisection).
133
+ - **`run-once` fires ONE catch-up**: the watermark rides the fire (`ScheduledFire.watermarkMs`,
134
+ `SQL_SCHEDULER_FIRE` `$4`), never a `markFired` behind it; `skip` fires the true latest missed
135
+ occurrence (`latestOccurrenceBy`, bisection).
136
+ - **A lease for a run that never STARTED is `abandon()`ed, never `release()`d** — the rate stamp
137
+ goes with it (`worker-admit.ts`). **A failed pass re-arms at the floor** (`worker-loop.ts`
138
+ catch); `timers` is the test seam.
137
139
  - **Every timer body catches before it finalises** (`void work().catch(log).finally(...)`).
138
140
  - **Suspension is control flow, and a SHED is not a suspension**: `StepSuspension` →
139
141
  `nack({ countsAsAttempt: false, park: true })`; a shed is `countsAsAttempt: false` without `park`,
@@ -166,10 +168,9 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
166
168
  - **An occurrence fires in ONE statement** (`SchedulerState.fire`, `SQL_SCHEDULER_FIRE`): the
167
169
  watermark is the fence, jobs insert only `where exists (select 1 from moved)`.
168
170
  - **`progress()` is throttled in `progress.ts`** and flushed before every settle. **`onSettled`
169
- (`settled.ts`) is the ONE ending hook** — `completed` (with `result`), `dead-lettered`,
170
- `dropped`, `refused` — AFTER a settle that LANDED, in the body's tenant scope,
171
- `ON_SETTLED_ATTEMPTS` tries, never rethrown (`X_JOB_ON_SETTLED_FAILED`), at most once across a
172
- crash. Never for a cancel, a retry or a suspension; no second hook beside it.
171
+ (`settled.ts`) is the ONE ending hook** — `completed`, `dead-lettered`, `dropped`, `refused` —
172
+ AFTER a settle that LANDED, in the body's tenant scope, `ON_SETTLED_ATTEMPTS` tries, never
173
+ rethrown, at most once across a crash. Never for a cancel, a retry or a suspension.
173
174
  - **Failed for good + `deadLetter: false` is DROPPED**: `nack({ fail })`, outcome `dropped`.
174
175
  - **Worker registry rows expire** (`worker-registry.ts`, TTL = visibility timeout); `stop()` forgets.
175
176
 
@@ -196,7 +197,9 @@ Long form: `docs/history/jobs.md`, "the cross-process wake".
196
197
  - **`idle-cost.test.ts` / `queue-wake.test.ts` count statements per idle minute** — scheduler 6,
197
198
  worker and relay 30 with no wake, 12 with. Raising one is a regression.
198
199
  - **The memory driver stores a payload's JSON form**, as pg binds it (`driver-settle-parity.test.ts`).
199
- - **The event bus has ONE clock**: `createPgEventBus` takes no `clock`; `bus.now()` is "asked at".
200
+ - **The event bus has ONE clock**: `createPgEventBus` takes no `clock`; `EventLookup.now()` is
201
+ REQUIRED, stamps a NEW wait, and calls the TIMEOUT (asked once the runner thinks time is up).
202
+ `purgeExpired()` is awaited and counted; `eventsPurgeTarget(bus)` is its `PurgeTarget`.
200
203
 
201
204
  ## Rules — the outbox
202
205
 
@@ -238,7 +241,7 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
238
241
  | `tenant.ts` | what tenant a run acts under: `JobTenant`, the declaration's backstop, the actor `executeJob` installs |
239
242
  | `backfill.ts` | `backfill()` — a factory over `job()`: the declaration, its checksum and its input |
240
243
  | `backfill-pass.ts` | one pass: the batched iteration, its cursor checkpoints and its ledger row |
241
- | `backfill-scope.ts` | which sweeps run across tenants — `tenant: 'none'` only, on the pass's own actor, for the pass's own life |
244
+ | `backfill-scope.ts` | which sweeps run across tenants (`tenant: 'none'` only) |
242
245
  | `backfill-ledger.ts` | `x_backfills` — the contract, `BACKFILL_STATUSES`, the checksum, the verdict, the memory ledger |
243
246
  | `backfill-registry.ts` | what was DECLARED: the `origin` stamp, `isBackfill`, `registeredBackfills` |
244
247
  | `backfill-gate.ts` | may this sweep run here and now — environment, `requires`, already-applied |
@@ -249,38 +252,39 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
249
252
  | `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements; an `action-job` projection is `X_ACTION_JOB_UNBRIDGED` |
250
253
  | `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
251
254
  | `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
255
+ | `steps-timeout.ts` / `steps-suspension.ts` | a step's ceiling; `StepSuspension` and the timed-out marker |
252
256
  | `outbox.ts` | staging in a `Tx`, the store seam, the ambient `JobsFacade` slot |
253
257
  | `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks |
254
258
  | `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
255
259
  | `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
256
- | `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one (drops a key with its last slot), `jobLeaseKey` |
260
+ | `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
257
261
  | `concurrency.ts` | `job.concurrency` as declared, resolved once: `KeyedConcurrency`, `WhenBusy`, the declaration and key refusals |
258
- | `errors-concurrency.ts` | every concurrency refusal's class — declaration, key, `X_JOB_KEY_BUSY`, the boot refusal. Codes stay declared in `errors.ts` |
262
+ | `errors-concurrency.ts` | every concurrency refusal's class; codes stay in `errors.ts` |
259
263
  | `worker-key-busy.ts` | one run refused by its key: settled `failed`, body never run |
260
264
  | `metrics.ts` | `queue_oldest_ready_seconds` and `queue_dead_jobs`, the two alertable gauges |
261
265
  | `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark, the atomic fire) + `createPgLeaseLeader` |
262
266
  | `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
263
267
  | `driver.ts` | `JobDriver` contract + wire records |
264
- | `driver-pg.ts` | default driver, and `createPgLeader` — the advisory-lock election a scheduler does NOT use |
268
+ | `driver-pg.ts` | default driver, and `createPgLeader` |
265
269
  | `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` — the ONE install point. Its comments carry no `;` and no `'` |
266
270
  | `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and `JOB_ROW_COLUMNS`; re-exported from `driver-pg-sql.ts` |
267
271
  | `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
268
- | `driver-memory.ts` | `x dev` / tests |
272
+ | `driver-memory.ts` / `claim-exhausted.ts` | `x dev` / tests; a buried row's log line and `onSettled` |
269
273
  | `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
270
274
  | `retry.ts` | the dead-letter decision, and this package's option names over core's `backoffDelay` — no curve of its own |
271
- | `retry-classification.ts` | the OTHER half of that decision: what the thrown error says, and the stop reason the row and the log carry |
275
+ | `retry-classification.ts` | the other half: what the thrown error says, and the stop reason |
272
276
  | `execute.ts` / `run-deadline.ts` | `executeJob` — one claimed job run and settled; the run's deadline: cancel, then fail |
273
277
  | `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
274
- | `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
275
- | `worker.ts` | `worker` role, claim loop, drain — and the one `AbortController` SIGTERM reaches every held run through |
278
+ | `renewal-timer.ts` | the renewal interval and its `stopped()` latch |
279
+ | `worker.ts` | `worker` role, claim round, drain |
276
280
  | `worker-admit.ts` | may this claimed job start: the limiter, the fleet slot, and the shed / refusal |
277
281
  | `worker-registry.ts` | one worker's registry row: announce, heartbeat, forget |
278
282
  | `worker-types.ts` | the worker's public contract: `WorkerOptions`, `WorkerStats`, `Worker` |
279
- | `drain-wait.ts` | the drain's wait, shared by both roles: settled, or abandoned at the `close` hook's budget |
280
- | `worker-run.ts` | one claimed job, wired: heartbeat, slot renewal, run signal, span — handed back in one `finally` |
281
- | `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
283
+ | `drain-wait.ts` | the drain's wait, shared by both roles |
284
+ | `worker-run.ts` | one claimed job, wired: heartbeat, slot renewal, run signal, span |
285
+ | `run-signal.ts` | the signal ONE run is cancelled by |
282
286
  | `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back — and the boot refusal |
283
- | `purge.ts` | `purge()` — a factory over `job()`: the retention sweep, its structural target seam and the hourly cron a host schedules it on |
287
+ | `purge.ts` | `purge()` — a factory over `job()`: the retention sweep and its target seam |
284
288
  | `task.ts` | the `task()` primitive + registry + the handle's surface + `registerTask` |
285
289
  | `scheduler.ts` | `scheduler` role: the dispatch round, catch-up, leader election, the drain |
286
290
  | `limits.ts` | per-tenant / per-queue / global concurrency + rate |
@@ -298,16 +302,11 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
298
302
  | `queue-wake.ts` / `driver-pg-wake-sql.ts` | the `LISTEN` session and its proof; which statement notifies |
299
303
  | `worker-loop.ts` | the claim loop's timer: wake, `again`, the kicks |
300
304
  | `driver-pg-outbox-sql.ts` | the `x_outbox` statements |
301
- | `errors-operator.ts` | `X_JOB_NOT_REMOVABLE`, `X_JOB_NOT_PROMOTABLE`, `X_JOB_ON_SETTLED_FAILED` classes |
305
+ | `errors-operator.ts` / `errors-requeue.ts` | the operator verbs' refusals; `requeue`'s |
302
306
 
303
307
  Which `-fixture.ts` harness each suite shares, and what each `.job.` suite proves:
304
308
  `docs/history/jobs.md`, "Moved 2026-10-01 — test harnesses".
305
309
 
306
- ## Commands
307
-
308
- ```
309
- bun test packages/jobs
310
- bun run --filter @ultimat3/jobs typecheck
311
- ```
310
+ Commands: `bun test packages/jobs` · `bun run --filter @ultimat3/jobs typecheck`.
312
311
 
313
312
  Why each rule above is shaped the way it is: [`docs/history/jobs.md`](../../docs/history/jobs.md).
package/README.md CHANGED
@@ -196,7 +196,13 @@ The event bus has ONE clock. `createPgEventBus({ executor })` stamps `publishedA
196
196
  with the database's `now()` — it takes no `clock` — and `bus.now()` answers that same clock. A
197
197
  consumer that wants "published after I asked" (`@ultimat3/scraping`'s `eventPrompt`) reads its
198
198
  "asked at" from `bus.now()`, never from its own process: a skew between two pods must not decide
199
- which answers count.
199
+ which answers count. `step.waitForEvent` stamps a new wait from the same clock
200
+ (`EventLookup.now()`), so a worker running ahead of the database still matches an event published
201
+ after it asked.
202
+ `step.waitForEvent` stamps a new wait from that clock (`EventLookup.now()`, required) and asks it
203
+ again before declaring a timeout, so a worker running ahead of the database still matches an event
204
+ published after it asked and does not give up early. `eventsPurgeTarget(bus)` is the bus as a
205
+ `purge()` target: `purgeExpired()` is awaited and answers the rows it removed.
200
206
 
201
207
  Step names are the replay key, so they must be deterministic and unique in a run — a
202
208
  duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
@@ -405,6 +411,11 @@ export const hourly = task({
405
411
  | at least once is safe here | a replayed delete removes rows that are already gone, and a row a purge deleted answers exactly as one that was never there |
406
412
  | two targets under one name | `X_INVARIANT`, before the first delete — `step.run` would raise `X_STEP_DUPLICATE` after one table was already empty |
407
413
 
414
+ The stored event bus is a target too: `eventsPurgeTarget(bus)` answers
415
+ `{ name: EVENTS_PURGE_TARGET, purgeExpired }` over `bus.purgeExpired()`, which is awaited and
416
+ answers the rows it removed — `x_job_events` only grew while nothing called it. Add it to
417
+ `targets`: `targets: () => [eventsPurgeTarget(eventBus())]`.
418
+
408
419
  `@ultimat3/cli`'s boot declares both halves over the three tables it owns, so an app gets the sweep
409
420
  without writing any of the above. It needs a `worker` to run it and a `scheduler` to fire it: a
410
421
  deployment with neither has no background work at all, and this is one more thing it does not do.
@@ -731,6 +742,20 @@ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driv
731
742
  The pg SQL is exported verbatim (`SQL_CLAIM`, `SQL_ENQUEUE`, `SQL_NACK`, …) so an agent
732
743
  debugging a stuck queue can read and run the exact statement.
733
744
 
745
+ **A lease that lapses on a row's final attempt is buried by the claim.** `claim` re-takes a
746
+ `running` row whose lease lapsed — the worker died — but one already at `attempt >= maxAttempts`
747
+ is settled `dead` in the claim statement itself (`SQL_CLAIM`'s second arm; `isFinalAttempt` on the
748
+ memory driver), with `lastError` set to `LEASE_LAPSED_FINAL_ATTEMPT`, counted `dead`, and never
749
+ handed out: a job that kills its worker reaches the dead-letter queue after `retry.attempts`
750
+ claims instead of taking a worker per visibility timeout, forever. The pass is told through
751
+ `ClaimOptions.onExhausted(rows)` — at most once per pass, once per row — and `announceExhausted`
752
+ (`claim-exhausted.ts`) is what a claim round does with them: `jobs.claim.exhausted` at `error`, and
753
+ the job's `onSettled`, and the worker's claim round does exactly that. The row carries
754
+ `maxAttempts` and not the policy, so the CALLER names the jobs declaring `retry.deadLetter: false`
755
+ (`ClaimOptions.dropExhausted` — the worker reads them off its registry): those rows are buried
756
+ `failed` and announced `dropped`, every other one `dead` / `dead-lettered`. A caller that names
757
+ none (`x jobs drain`) buries every such row `dead`.
758
+
734
759
  A driver you write reads a nack's target state through `nackState(options)` — `dead`
735
760
  (`deadLetter`), `failed` (`fail`, terminal and out of the dead-letter queue), `suspended` (`park`)
736
761
  or `ready` — so a new branch cannot land in one driver and not another. Its lease store implements
@@ -1071,7 +1096,9 @@ the pg and the memory driver alike. `x jobs`, `/_x` and a dashboard read nothing
1071
1096
  |---|---|---|
1072
1097
  | `list(filter)` | one page, newest first; `filter.after = jobCursor(lastRow)` for the next, `filter.before = jobCursor(firstRow)` for the previous (the rows nearest the cursor, still newest first; never both). Filters: `queue`, `name`, `state`, `tenantId`, `idPrefix`, `createdFrom`, `createdTo` | `MAX_JOB_PAGE` = 200 rows; `DEFAULT_JOB_PAGE` = 100. A page shorter than its limit is the last. A larger `limit`, or a cursor no page produced, is `X_JOB_PAGE_INVALID` |
1073
1098
  | `remove(id)` | delete one job and its steps. A running one is `X_JOB_NOT_REMOVABLE` | — |
1074
- | `requeueMany({ state, queue?, name?, tenantId? })` | re-queue finished rows; a row whose key a live job holds stays | `MAX_BULK_ROWS` = 1,000 per call; answers `{ affected, remaining }` |
1099
+ | `requeue(id, { fromStep? })` | re-queue ONE finished job (`REQUEUEABLE_STATES`). A live one is `X_JOB_NOT_REQUEUEABLE`, a key a live job holds `X_JOB_DUPLICATE`, an id nobody queued `X_JOB_NOT_FOUND` — on both drivers. `fromStep` drops that step and every later one in the statement that moves the row | — |
1100
+ | `cancel(id, reason?)` | stop a LIVE job (`LIVE_STATES`: ready, delayed, running, suspended). A done, failed, dead or cancelled row is left exactly as it ended and answers `undefined` — `cancelJob` turns that into `X_JOB_NOT_CANCELLABLE` | — |
1101
+ | `requeueMany({ state, queue?, name?, tenantId? })` | re-queue finished rows; a row whose key a live job holds stays, and of several finished rows sharing one key the oldest is requeued | `MAX_BULK_ROWS` = 1,000 per call, spent only on rows it can move; answers `{ affected, remaining }`, where `remaining` is what a second call could still move — a held row is not counted, so "call until zero" ends |
1075
1102
  | `removeMany({ state, queue?, name?, tenantId? })` | delete rows in any state but `running` | the same |
1076
1103
  | `promoteMany({ state, queue?, name?, tenantId? })` | `promote` over a set: every row still waiting on its `runAt` is due now. `state` is `delayed` or `ready` (`PROMOTABLE_STATES`) | the same; `remaining` is the rows still waiting |
1077
1104
  | `promote(id)` | a job waiting on its `runAt` — delayed, or backing off — is due now | — |
@@ -1133,6 +1160,7 @@ export const syncAccountWatched = job({
1133
1160
  |---|---|---|---|
1134
1161
  | the body returned and the ack landed | `completed` | `input`, `result` — what `run` returned, typed from it | `done` |
1135
1162
  | failed for good, parked in the dead-letter queue | `dead-lettered` | `input` (or `undefined`: the stored payload no longer parses), `error`, `code` | `dead` |
1163
+ | its lease lapsed on the final attempt and the claim buried it — every worker that took it died | `dead-lettered` | `input`, `error` (`LEASE_LAPSED_FINAL_ATTEMPT`), `code: undefined` — nothing was thrown | `dead` |
1136
1164
  | failed for good on `retry.deadLetter: false` | `dropped` | the same | `failed` |
1137
1165
  | `whenBusy: 'fail'` over a busy key — the body never ran | `refused` | the same, `code: 'X_JOB_KEY_BUSY'` | `failed` |
1138
1166
 
@@ -1198,13 +1226,14 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
1198
1226
  | `X_ABORTED` | a cancelled attempt tried to write a step — core's code, not a second name for it |
1199
1227
  | `X_JOB_LEASE_LOST` | the job was cancelled, or its lease lapsed and the queue re-delivered it, while this worker was still running it |
1200
1228
  | `X_JOB_SLOT_LOST` | the fleet `concurrency` slot this run held was taken by another worker — a different row on a different clock from the lease above |
1201
- | `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that already finished, or a driver with no `cancel` |
1229
+ | `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that is not live — done, failed, dead or already cancelled — an id no queue holds, or a driver with no `cancel` |
1230
+ | `X_JOB_NOT_FOUND` | `requeue` was asked for an id the queue does not hold — one answer on both drivers. Fix: `x jobs ls --json` |
1202
1231
  | `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency`, plain or keyed, and the driver has no lease store |
1203
1232
  | `X_JOB_KEY_BUSY` | a run claimed under `whenBusy: 'fail'` while its key already held `limit` runs. Classified `terminal`. Fix: `x jobs ls --name <job> --state running --json` |
1204
1233
  | `X_JOB_DECLARATION_INVALID` | `job()` is missing a required field, declares a `concurrency` no worker can honour (plain or keyed), or its `concurrency.key` answered an empty, non-string or over-long key at enqueue |
1205
1234
  | `X_JOB_NOT_REMOVABLE` | `remove` / `removeMany` reached a running job. Fix: `x jobs cancel <id> --json` |
1206
1235
  | `X_JOB_NOT_PROMOTABLE` | `promote` reached a job not waiting on its run time |
1207
- | `X_JOB_PAGE_INVALID` | `list()` was asked for more than `MAX_JOB_PAGE` rows, or handed a cursor no page produced. Fix: `list({ limit: 200, after: jobCursor(lastRow) })` |
1236
+ | `X_JOB_PAGE_INVALID` | `list()` was asked for more than `MAX_JOB_PAGE` rows, or handed a cursor no page produced. The cause names which cursor (`after` / `before`). Fix: `x jobs ls --limit 200 --json` |
1208
1237
  | `X_JOB_ON_SETTLED_FAILED` | a declared `onSettled` threw on every one of its tries; logged and reported, never thrown |
1209
1238
  | `X_NOT_IMPLEMENTED` | redis / nats driver |
1210
1239
 
@@ -1240,6 +1269,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
1240
1269
  | `JobMaxAttemptsError` | `X_JOB_MAX_ATTEMPTS` | `src/errors.ts` |
1241
1270
  | `JobNameTakenError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
1242
1271
  | `JobNotCancellableError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
1272
+ | `JobNotFoundError` | `X_JOB_NOT_FOUND` | `src/errors-requeue.ts` |
1243
1273
  | `JobNotPromotableError` | `X_JOB_NOT_PROMOTABLE` | `src/errors-operator.ts` |
1244
1274
  | `JobNotRemovableError` | `X_JOB_NOT_REMOVABLE` | `src/errors-operator.ts` |
1245
1275
  | `JobNotRequeueableError` | `X_JOB_NOT_REQUEUEABLE` | `src/errors-requeue.ts` |
@@ -1247,7 +1277,6 @@ a job boundary the class is gone and the `code` is what survives — match on th
1247
1277
  | `JobPageInvalidError` | `X_JOB_PAGE_INVALID` | `src/errors-operator.ts` |
1248
1278
  | `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
1249
1279
  | `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
1250
- | `JobsNotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
1251
1280
  | `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
1252
1281
  | `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
1253
1282
  | `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,10 +32,10 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "23.0.0",
36
- "@ultimat3/db": "23.0.0",
37
- "@ultimat3/entity": "23.0.0",
38
- "@ultimat3/schema": "23.0.0",
39
- "@ultimat3/time": "23.0.0"
35
+ "@ultimat3/core": "24.0.0",
36
+ "@ultimat3/db": "24.0.0",
37
+ "@ultimat3/entity": "24.0.0",
38
+ "@ultimat3/schema": "24.0.0",
39
+ "@ultimat3/time": "24.0.0"
40
40
  }
41
41
  }
@@ -10,7 +10,7 @@
10
10
  // become — the checkpoints are transactional with the work, and this row is not.
11
11
 
12
12
  import type { Clock } from '@ultimat3/core';
13
- import { finiteCount, systemClock } from '@ultimat3/core';
13
+ import { describeValue, finiteCount, systemClock, UltimateError } from '@ultimat3/core';
14
14
  import { nowMs } from './clock';
15
15
 
16
16
  /**
@@ -56,6 +56,27 @@ export interface BackfillFilter {
56
56
  readonly limit?: number | undefined;
57
57
  }
58
58
 
59
+ /** What Postgres prints for a `uuid` — the one spelling both ledgers store identically. */
60
+ const RUN_ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
61
+
62
+ /**
63
+ * `BackfillFilter.runId`, refused by BOTH ledgers' `list` when it is not a uuid. `run_id` is a
64
+ * `uuid` column: Postgres raised a raw `22P02` where the memory ledger answered an empty page, so
65
+ * a mistyped id read as "no such pass" under `x dev` and as a database fault in production. The
66
+ * shape `outbox.ts` holds an enqueue's `runId` to — lowercase, so the two never disagree on case.
67
+ */
68
+ export function listedRunId(subject: string, runId: unknown): string | undefined {
69
+ if (runId === undefined) return undefined;
70
+ if (typeof runId === 'string' && RUN_ID_SHAPE.test(runId)) return runId;
71
+ throw new UltimateError({
72
+ code: 'X_ID_INVALID',
73
+ // `describeValue`, never the value: what arrives here wrong is as often a key as a typo.
74
+ cause: `${subject} backfills.list was filtered by runId ${describeValue(runId)}, and a run id is a lowercase uuid (8-4-4-4-12 hex) — x_backfills.run_id is a uuid column`,
75
+ fix: 'x db backfill --list --json',
76
+ meta: { option: 'runId' },
77
+ });
78
+ }
79
+
59
80
  /**
60
81
  * Durable, and therefore behind the queue driver: `x_backfills` ships in the same DDL as `x_jobs`
61
82
  * and `x_job_steps` (`driver-pg-sql.ts`), so a ledger a pass cannot write is a queue it could not
@@ -169,6 +190,7 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
169
190
  },
170
191
  // `async`, so a refused limit REJECTS here as it does on the pg ledger — see `driver-memory.ts`.
171
192
  async list(filter = {}) {
193
+ const runId = listedRunId('the memory ledger', filter.runId);
172
194
  // Reversed BEFORE the sort: the test clock is frozen, so two rows share a `startedAt` and a
173
195
  // stable sort would hand back the oldest of them first under a "newest first" contract.
174
196
  const rows = [...runs.values()]
@@ -176,7 +198,7 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
176
198
  .sort((a, b) => b.startedAt - a.startedAt)
177
199
  .filter((run) => filter.name === undefined || run.name === filter.name)
178
200
  .filter((run) => filter.status === undefined || run.status === filter.status)
179
- .filter((run) => filter.runId === undefined || run.runId === filter.runId)
201
+ .filter((run) => runId === undefined || run.runId === runId)
180
202
  .slice(0, finiteCount('the backfill ledger list', 'limit', filter.limit ?? 100));
181
203
  return rows;
182
204
  },
@@ -164,7 +164,13 @@ export async function backfillPass<Row>(
164
164
  // refusing it — the same degradation `introspect` already has.
165
165
  const ledger = jobDriver()?.backfills;
166
166
 
167
- const previous = await completedRun(ledger, name);
167
+ const completed = await completedRun(ledger, name);
168
+ // Its OWN row is not a previous pass: a worker that died between `ledger.finish` and the ack is
169
+ // handed the same run again, and skipping there reported `{ skipped: true, previousRunId:
170
+ // <itself> }` for a pass that swept. It falls through and replays — every batch is served from
171
+ // its checkpoint, nothing is read or handled twice, and the report is the one it first made.
172
+ const redelivered = completed?.runId === runId;
173
+ const previous = redelivered ? undefined : completed;
168
174
  const verdict = decideBackfill(previous, checksum, args.input.force === true);
169
175
  if (!verdict.run) {
170
176
  if (verdict.changed) {
@@ -196,7 +202,14 @@ export async function backfillPass<Row>(
196
202
  definition.tenant,
197
203
  args.ctx,
198
204
  async (ctx): Promise<BackfillReport> => {
199
- await ledger?.start({ runId, name, checksum, appVersion: appVersion() });
205
+ // A redelivered pass leaves its completed row ALONE until a batch body actually runs (an
206
+ // operator's `retryFromStep` re-opens one): replaying checkpoints sweeps nothing, so there is
207
+ // nothing to record and no convergence to re-ask — rows that started matching since would
208
+ // otherwise turn a finished pass into a failed one.
209
+ let recording = !redelivered;
210
+ const open = (): Promise<void> | undefined =>
211
+ ledger?.start({ runId, name, checksum, appVersion: appVersion() });
212
+ if (recording) await open();
200
213
 
201
214
  let cursor: string | null = null;
202
215
  let rows = 0;
@@ -242,6 +255,10 @@ export async function backfillPass<Row>(
242
255
  const iteration = await iterate();
243
256
  const next = await iteration.pull.next();
244
257
  if (next.done === true) return { cursor: null, rows: 0 };
258
+ if (!recording) {
259
+ recording = true;
260
+ await open();
261
+ }
245
262
  await definition.handle({ rows: next.value, ctx, signal, index });
246
263
  swept = true;
247
264
  return { cursor: iteration.batches.cursor, rows: next.value.length };
@@ -271,10 +288,10 @@ export async function backfillPass<Row>(
271
288
  // this converge", asked once, where the answer is finally decidable. Inside the `try` so the
272
289
  // ledger records the attempt as `failed`: a pass that left rows behind must not write the
273
290
  // completed row that stops the next deploy re-running it.
274
- await assertConverged(definition, ctx, rows);
291
+ if (recording) await assertConverged(definition, ctx, rows);
275
292
  } catch (error) {
276
293
  // Control flow, not a failure: a suspended run is parked and will be back on this step.
277
- if (!isStepSuspension(error)) await markFailed(ledger, runId, rows);
294
+ if (!isStepSuspension(error) && recording) await markFailed(ledger, runId, rows);
278
295
  throw error;
279
296
  } finally {
280
297
  // Whatever the iteration holds belongs to this attempt, and an attempt that failed, was
@@ -282,7 +299,7 @@ export async function backfillPass<Row>(
282
299
  await live?.batches.close();
283
300
  }
284
301
 
285
- await ledger?.finish(runId, { status: 'completed', rows });
302
+ if (recording) await ledger?.finish(runId, { status: 'completed', rows });
286
303
  return { name, batches, rows, skipped: false };
287
304
  },
288
305
  );
@@ -0,0 +1,76 @@
1
+ // The claim round's half of a burial: a row the claim settled `dead` (`ClaimOptions.onExhausted`)
2
+ // has no body to end it and no settle to announce it, so the pass that buried it says so — one log
3
+ // line an operator can alert on, and the job's `onSettled`, exactly as a dead letter a worker
4
+ // settled itself would get.
5
+
6
+ import type { Ctx } from '@ultimat3/core';
7
+ import { logger, renderThrowable } from '@ultimat3/core';
8
+ import type { JobRecord } from './driver';
9
+ import { getJob } from './job';
10
+ import { announceSettled } from './settled';
11
+
12
+ export interface AnnounceExhaustedOptions {
13
+ /** What `onExhausted` handed this pass — every row the claim buried, as it now stands. */
14
+ readonly exhausted: readonly JobRecord[];
15
+ readonly workerId: string;
16
+ /** The worker's own context: what the job's `onSettled` is scoped from, as a body would be. */
17
+ readonly context: () => Ctx;
18
+ }
19
+
20
+ /** What one pass buried, by how each row ended — what the worker's own counters take. */
21
+ export interface ExhaustedCounts {
22
+ readonly deadLettered: number;
23
+ readonly dropped: number;
24
+ }
25
+
26
+ /**
27
+ * Announce every row one claim pass buried, and answer how each ended. Once per row, because the
28
+ * claim buries a row once: the statement that settled it is the only one that hands it to
29
+ * `onExhausted`. A row buried `failed` is a job declaring `retry.deadLetter: false`
30
+ * (`ClaimOptions.dropExhausted`) — `dropped`, as its own nack would have ended it. Never rejects:
31
+ * `announceSettled` owns a hook's failure, and a `context()` that throws is logged.
32
+ */
33
+ export async function announceExhausted(
34
+ options: AnnounceExhaustedOptions,
35
+ ): Promise<ExhaustedCounts> {
36
+ let dropped = 0;
37
+ for (const record of options.exhausted) {
38
+ const error = record.lastError ?? '';
39
+ const outcome = record.state === 'failed' ? 'dropped' : 'dead-lettered';
40
+ if (outcome === 'dropped') dropped += 1;
41
+ // `error`, not `warn`: nobody recovered from this one, and a job that outlives every worker
42
+ // that takes it is a crash loop somebody has to look at.
43
+ logger.error('jobs.claim.exhausted', {
44
+ workerId: options.workerId,
45
+ job: record.name,
46
+ jobId: record.id,
47
+ attempt: record.attempt,
48
+ maxAttempts: record.maxAttempts,
49
+ outcome,
50
+ error,
51
+ });
52
+ const handle = getJob(record.name);
53
+ if (handle === undefined) continue;
54
+ let ctx: Ctx;
55
+ try {
56
+ ctx = options.context();
57
+ } catch (raised) {
58
+ // The app's own function, asked AFTER the row was settled: thrown, it failed the round.
59
+ logger.error('jobs.claim.exhausted-unannounced', {
60
+ workerId: options.workerId,
61
+ job: record.name,
62
+ jobId: record.id,
63
+ error: renderThrowable(raised),
64
+ });
65
+ continue;
66
+ }
67
+ await announceSettled({
68
+ handle,
69
+ claimed: record,
70
+ ctx,
71
+ // No code: nothing was thrown, and `JobFailed.code` is never invented.
72
+ settlement: { outcome, error, code: undefined },
73
+ });
74
+ }
75
+ return { deadLettered: options.exhausted.length - dropped, dropped };
76
+ }
@@ -134,7 +134,7 @@ export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
134
134
  const before =
135
135
  filter.before === undefined
136
136
  ? undefined
137
- : parseJobCursor(filter.before, 'the memory driver list');
137
+ : parseJobCursor(filter.before, 'the memory driver list', 'before');
138
138
  const { idPrefix, createdFrom, createdTo, tenantId } = filter;
139
139
  const matching = [...jobs.values()]
140
140
  .filter((record) => filter.queue === undefined || record.queue === filter.queue)
@@ -168,16 +168,38 @@ export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
168
168
  `requeueMany was asked for ${filter.state} jobs, and only a finished job can be requeued`,
169
169
  "pass state: 'dead', 'failed', 'cancelled' or 'done' to requeueMany",
170
170
  );
171
- const result = await bulk(filter, (record) => {
172
- // A key a live job holds stays where it is — the single requeue's `X_JOB_DUPLICATE`,
173
- // answered as "not this one" so one conflict does not fail a thousand rows.
174
- if (state.liveHolder(record) !== undefined) return false;
175
- state.settle(record.id, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
176
- return true;
177
- });
171
+ // A key a live job holds stays where it is — the single requeue's `X_JOB_DUPLICATE`,
172
+ // answered as "not this one" so one conflict does not fail a thousand rows.
173
+ const free = (record: JobRecord): boolean => state.liveHolder(record) === undefined;
174
+ // `SQL_JOB_REQUEUE_MANY`'s candidates: no live holder, and ONE row per idempotency namespace
175
+ // — the oldest — both decided BEFORE the bound. Decided inside `apply`, a held row spent a
176
+ // slot of `MAX_BULK_ROWS` and was then skipped: a thousand held rows ahead of one free row
177
+ // answered `affected: 0` here and `affected: 1` on Postgres.
178
+ const oldest = new Map<string, JobRecord>();
179
+ for (const record of jobs.values()) {
180
+ if (!inBulk(record, filter) || !free(record)) continue;
181
+ const key = JSON.stringify([record.name, record.tenantId ?? '', record.idempotencyKey]);
182
+ const seen = oldest.get(key);
183
+ if (seen === undefined || newerThan(seen, record)) oldest.set(key, record);
184
+ }
185
+ const candidates = new Set([...oldest.values()].map((record) => record.id));
186
+ const result = await bulk(
187
+ filter,
188
+ (record) => {
189
+ if (!free(record)) return false;
190
+ state.settle(record.id, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
191
+ return true;
192
+ },
193
+ (record) => candidates.has(record.id),
194
+ );
178
195
  // The pg driver's `SQL_WAKE`, in-process: an operator's repair starts now.
179
196
  if (result.affected > 0) signalEnqueued(filter.queue);
180
- return result;
197
+ // What a second call could still move: rows of the filter whose key no live job holds. A
198
+ // row requeued just now holds its key, so its dead siblings left with it.
199
+ const remaining = [...jobs.values()].filter(
200
+ (record) => inBulk(record, filter) && free(record),
201
+ ).length;
202
+ return { affected: result.affected, remaining };
181
203
  },
182
204
 
183
205
  async promoteMany(filter) {