@ultimat3/jobs 22.15.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 (72) hide show
  1. package/CLAUDE.md +165 -124
  2. package/README.md +360 -21
  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/concurrency.ts +144 -0
  8. package/src/counters.ts +105 -0
  9. package/src/describe.ts +29 -0
  10. package/src/driver-memory-operator.ts +319 -0
  11. package/src/driver-memory.ts +146 -59
  12. package/src/driver-nats.ts +7 -4
  13. package/src/driver-pg-ddl.ts +71 -0
  14. package/src/driver-pg-jobs-sql.ts +96 -25
  15. package/src/driver-pg-operator-sql.ts +323 -0
  16. package/src/driver-pg-operator.ts +316 -0
  17. package/src/driver-pg-outbox-sql.ts +110 -0
  18. package/src/driver-pg-rows.ts +34 -3
  19. package/src/driver-pg-settle-sql.ts +71 -0
  20. package/src/driver-pg-sql.ts +171 -151
  21. package/src/driver-pg-wake-sql.ts +76 -0
  22. package/src/driver-pg.ts +150 -105
  23. package/src/driver-redis.ts +7 -4
  24. package/src/driver.ts +133 -35
  25. package/src/enqueue-signal.ts +50 -0
  26. package/src/errors-concurrency.ts +91 -0
  27. package/src/errors-operator.ts +102 -0
  28. package/src/errors-requeue.ts +21 -3
  29. package/src/errors.ts +18 -30
  30. package/src/events-pg.ts +45 -45
  31. package/src/events.ts +38 -2
  32. package/src/execute.ts +161 -81
  33. package/src/export.ts +23 -4
  34. package/src/heartbeat.ts +9 -3
  35. package/src/idle-backoff.ts +74 -0
  36. package/src/index.ts +115 -16
  37. package/src/inspect-operator.ts +49 -0
  38. package/src/inspect.ts +46 -5
  39. package/src/introspection.ts +274 -0
  40. package/src/job.ts +83 -21
  41. package/src/leases.ts +49 -9
  42. package/src/limits.ts +33 -7
  43. package/src/metrics.ts +6 -1
  44. package/src/outbox-pg.ts +4 -2
  45. package/src/outbox-relay.ts +114 -39
  46. package/src/outbox.ts +59 -8
  47. package/src/progress.ts +78 -0
  48. package/src/purge.ts +1 -1
  49. package/src/queue-wake.ts +219 -0
  50. package/src/redact-input.ts +26 -0
  51. package/src/renewal-timer.ts +28 -10
  52. package/src/retry-classification.ts +13 -1
  53. package/src/retry.ts +10 -2
  54. package/src/run-deadline.ts +57 -0
  55. package/src/scheduler-leader.ts +26 -0
  56. package/src/scheduler-occurrences.ts +76 -0
  57. package/src/scheduler-pg.ts +58 -4
  58. package/src/scheduler-state.ts +73 -0
  59. package/src/scheduler.ts +150 -111
  60. package/src/settled.ts +161 -0
  61. package/src/steps-suspension.ts +46 -0
  62. package/src/steps-timeout.ts +33 -0
  63. package/src/steps.ts +55 -81
  64. package/src/worker-admit.ts +167 -0
  65. package/src/worker-fleet-slots.ts +157 -54
  66. package/src/worker-hand-back.ts +6 -1
  67. package/src/worker-key-busy.ts +107 -0
  68. package/src/worker-loop.ts +169 -0
  69. package/src/worker-registry.ts +118 -0
  70. package/src/worker-run.ts +9 -0
  71. package/src/worker-types.ts +23 -0
  72. package/src/worker.ts +142 -130
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,48 +44,52 @@ 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`). A shipped table grows by `alter table ... add column if not exists`. Its comments
49
- carry NO apostrophes and NO semicolons (`dev-queue.ts` splits on `;`; `driver-pg-sql.test.ts` checks
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
+ `packages/cli/src/framework-schema.ts`. A shipped table grows by `alter table ... add column if
50
+ not exists`. Its comments carry NO apostrophes and NO semicolons (`dev-queue.ts` splits on `;`; `driver-pg-sql.test.ts` checks
50
51
  quote parity).
51
52
  - **`claim({ queues: [] })` is REFUSED by every driver** (`assertClaimQueues`,
52
- `X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's `claim`/`list`/`deadLetters` are `async` so a
53
- refusal rejects on both.
54
- - **`ack` and `nack` are FENCED on `state = 'running'`** — that is what makes cancellation possible.
55
- `heartbeat` answers a boolean, read as `held === false` (never `!held`).
56
- - **A driver's semantics are pinned in ONE test beside the pg statement** (`driver-parity.test.ts`):
57
- list order, the `attempt` floor, `SQL_LEASE_RENEW` fencing on `expires_at > now()` and `holder`,
58
- `SQL_STATS` one bucket per row.
53
+ `X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's reads are `async` so a refusal rejects on both.
54
+ - **`ack`, `nack`, `heartbeat` and `recordProgress` are FENCED on `state = 'running'` AND the
55
+ CLAIM** — `{ workerId, claim }`, `claimOf(claimed)`; `x_jobs.claims` is moved by the claim and
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.**
59
65
  - **No read returns a WHOLE row** — `driver-pg-sql.test.ts` scans every production file here
60
66
  (discovered, comments stripped) for `select *`/`returning *`.
61
67
  - Drivers implement the six `JobDriver` methods plus optional `introspect`, `backfills`, `leases`. New
62
68
  capabilities go behind the interface. `inspect.ts` returns plain JSON for CLI, `/_x` and MCP.
63
- - Step results are persisted BEFORE the step returns. All time is epoch ms from an injected `Clock`
64
- (`nowMs()` in `clock.ts`).
69
+ - Step results are persisted BEFORE the step returns. All time is epoch ms (`nowMs()`, `clock.ts`).
65
70
  - **`createPgLeader` is correct only on a DEDICATED connection**; boot uses `createPgLeaseLeader`.
66
- - `driver-redis.ts` / `driver-nats.ts` are honest `X_NOT_IMPLEMENTED` stubs.
67
71
 
68
72
  ## Rules — numbers and limits
69
73
 
70
74
  - **Every numeric knob is refused when not FINITE** — core's `finiteOption()` (a bound) and
71
75
  `finiteCount()` (a count, caller's minimum); `worker-options.ts` is where `createWorker` reads them.
72
- **`bun run finite-bounds` is a floor, never proof**: `createLimiter`'s five numbers (`perTenant`,
73
- `perQueue`, `global`, `ratePerTenant.limit` / `.windowMs`) are screened with min 0 (zero is a HARD
74
- STOP). **A row count is `finiteCount` (min 0)** for driver parity (`list`, `deadLetters`, the
75
- 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.
76
78
  - **`WorkerOptions.concurrency` is read by OWN key** — a queue named `constructor`
77
79
  (`worker-slots.test.ts`).
78
80
  - **`job.concurrency` is enforced by `JobDriver.leases`** (one row per held slot in `x_job_leases`);
79
81
  `limits.ts` is the per-process fast path. No lease store + a declared `concurrency` makes `start()`
80
82
  throw `X_JOB_CONCURRENCY_UNENFORCEABLE`.
81
- - **`limits.ts`'s per-tenant state is BOUNDED**: a zero counter is deleted; `starts`/`refusals` swept
82
- and capped at `DEFAULT_MAX_LIMIT_TENANTS`, evicting the LEAST throttled first. `LimitSnapshot.tracked`
83
- publishes both sizes.
84
- - **`clock.ts`'s conversion is `finiteDurationMs(duration, subject, option)`**: floor `finiteOption`
85
- (negative and zero are shipped behaviour), `subject`/`option` REQUIRED, and `Finite` in the name for
86
- the ratchet. `duration-bounds.test.ts`. `parseDuration` and `nextCronOccurrence` are normalised in
87
- one place each (`finiteDurationMs`, `defaultCronResolver`).
83
+ - **`concurrency` is resolved ONCE** (`concurrency.ts`): a number, or `{ key, limit, whenBusy }` per
84
+ key. The handle carries `concurrency` (the limit), `whenBusy` (set exactly when keyed) and
85
+ `concurrencyKeyFor()` — a METHOD. Lease key `job-key:<encoded name>:<key>`; plain `job:<name>`.
86
+ A bad cap is `X_JOB_DECLARATION_INVALID`.
87
+ - **`whenBusy: 'fail'` refuses only on EVIDENCE another run holds the key** (`LeaseStore.holders`,
88
+ required): a run meets its own leftover slot. The refusal is `nack({ fail, countsAsAttempt:
89
+ false })` → `failed`, outcome `refused` (`worker-key-busy.ts`), never `executeJob`.
90
+ - **A key underivable at claim is `SlotGrant` `undecidable`** → `executeJob({ refusal })`: the
91
+ ATTEMPT fails, never the round.
92
+ - **`finalAttempt` is `isFinalAttempt(handle.retry, attempt)`**, REQUIRED on `JobRunArgs`.
88
93
 
89
94
  ## Rules — the worker and the scheduler
90
95
 
@@ -105,36 +110,32 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
105
110
  letter), read off the SIGNAL. `WorkerStats.interrupted`; `worker-drain-signal.test.ts`.
106
111
  - **The drain waits out the claim round it races** — `tick()` registers its round in `rounds`
107
112
  synchronously with its guard.
108
- - **A fleet slot is taken INSIDE a `try`, released AWAITED, and a renewal answering `false` CANCELS the
109
- 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.
110
115
  - **The run's signal is a controller this worker owns** (`run-signal.ts`), never `AbortSignal.any`.
111
- - **A lease is HELD, not owned** (`heartbeat.ts`): one failed renewal is `jobs.heartbeat.failed`; a
112
- whole window without one landing is `jobs.lease.lost` + `recordLeaseLost(queue)`, measured on this
113
- 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.
114
118
  - **A renewal is decided against `stopped()`** (`renewal-timer.ts`, re-read after every await); the
115
- interval is `unref`ed.
119
+ interval is `unref`ed; every renewal is armed through `WorkerOptions.schedule`.
116
120
  - **Settlement is not part of the retry decision**: `driver.ack` sits after the `try`.
117
- - **The retry decision reads the ERROR** (`retry-classification.ts` composes around `nextRetry`;
118
- `nextRetryForError` is `execute.ts`'s only caller). **`classifyThrown` never reads `error.retry`
119
- alone** — core's `declaredErrorRetry(code)`. `retry-after` reuses the nack delay
120
- (`meta.retryAfterSeconds`, clamped). The verdict is published (`stop`, `stopReason`,
121
- `recordedFailure`).
122
- - **The backoff arithmetic is core's** (`backoffDelay` via `backoffDelayMs`), `jitter: true` = EQUAL.
123
- `RetryPolicy`, `DEFAULT_RETRY`, `retrySchedule()` unchanged; `BackoffStrategy` aliases core's
124
- `BackoffCurve`. `retry-core-parity.test.ts`. **`classifyThrown` / `statedDelayMs` are core's,
125
- re-exported** (pinned by identity).
121
+ - **The retry decision reads the ERROR, on core's backoff curve** (`retry-classification.ts`,
122
+ `backoffDelayMs`): `docs/history/jobs.md`, "Moved 2026-10-01 — retry and bounds".
126
123
  - **The claim loop re-arms on the PASS, never the jobs**; `tick()` resolves with that pass's executions.
127
124
  - **A deadline CANCELS, then fails the attempt**, never the reverse (`raceTimeout`, `withStepTimeout`);
128
125
  the attempt is cancelled in `executeJob`'s `finally`.
129
- - **A cancelled runner writes NOTHING** — `put()` in `steps.ts` is the one fence (`X_ABORTED`);
130
- `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.
131
128
  - **`traceparent` is stamped at ENQUEUE time, in `outbox.ts`**, and an empty `spanId` sends none.
132
129
  - **The scheduler runs one dispatch round at a time**; every other `tick()` joins it; `stop()` waits it
133
130
  out before `leader.release()`. Same two-hook rule; an ABANDONED round does not release
134
- (`jobs.scheduler.drain-abandoned`). **It asks `leader.acquire()` every round AND before every task**
135
- (`stillLeading()`).
136
- - **`run-once` fires ONE catch-up** (marks `at`); `skip` fires the true latest missed occurrence
137
- (`latestOccurrenceBy`, bisection).
131
+ (`jobs.scheduler.drain-abandoned`). `stillLeading()` is asked every round and before every task,
132
+ and answers from memory inside `renewEveryMs`.
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.
138
139
  - **Every timer body catches before it finalises** (`void work().catch(log).finally(...)`).
139
140
  - **Suspension is control flow, and a SHED is not a suspension**: `StepSuspension` →
140
141
  `nack({ countsAsAttempt: false, park: true })`; a shed is `countsAsAttempt: false` without `park`,
@@ -147,63 +148,91 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
147
148
  - **`x jobs cancel` binds to `cancelJob(driver, id, reason?)`, which refuses** a finished job or a
148
149
  driver with no `cancel` (`X_JOB_NOT_CANCELLABLE`).
149
150
 
151
+ ## Rules — the operator surface
152
+
153
+ - **Every operator capability is a member of `JobIntrospection`** (`introspection.ts`), implemented
154
+ by BOTH drivers, bounds as constants beside it; `operator-surface*-fixture.ts` is their parity
155
+ suite (memory + real pg).
156
+ - **A settle answers whether it landed**; a miss is `jobs.settle.unowned`, not a throw.
157
+ - **The counter moves in the SETTLING statement** (`driver-pg-settle-sql.ts`): one-minute bucket per
158
+ job name; `nackOutcome()` decides what a nack adds (a shed, a suspension, a drain: nothing), and
159
+ `ack({ counted: false })` — `x jobs drain` — adds nothing. The scheduler leader folds tiers
160
+ (`rollupCounters`), at most once a bucket.
161
+ - **`list()` is KEYSET** (`after: jobCursor(lastRow)`, newest first by `(created_at, id)`); the pg
162
+ seek reads the cursor row's own `created_at` — the cursor's ms is rounded. A page past
163
+ `MAX_JOB_PAGE` or a foreign cursor is `X_JOB_PAGE_INVALID`, never a bare invariant.
164
+ - **`taskFires()` is the last occurrence that DISPATCHED**, never the watermark (arming and skipping
165
+ move that): `SQL_SCHEDULER_FIRE` writes it, `fireThroughDriver` calls `recordTaskFire`.
166
+ - **A pause is a ROW the claim reads** (`x_job_pauses`, `not exists` in `SQL_CLAIM`), never a column
167
+ on `x_jobs`. A paused TASK keeps its watermark, so resume is its own `catchUp`.
168
+ - **An occurrence fires in ONE statement** (`SchedulerState.fire`, `SQL_SCHEDULER_FIRE`): the
169
+ watermark is the fence, jobs insert only `where exists (select 1 from moved)`.
170
+ - **`progress()` is throttled in `progress.ts`** and flushed before every settle. **`onSettled`
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.
174
+ - **Failed for good + `deadLetter: false` is DROPPED**: `nack({ fail })`, outcome `dropped`.
175
+ - **Worker registry rows expire** (`worker-registry.ts`, TTL = visibility timeout); `stop()` forgets.
176
+
177
+ ## Rules — idle cost and the wake
178
+
179
+ Long form: `docs/history/jobs.md`, "the cross-process wake".
180
+
181
+ - **An idle round touches no store.** The scheduler keeps each watermark and its next occurrence in
182
+ memory while it leads (`known`), trusts a lease for `LeaderElection.renewEveryMs`, reads the pause
183
+ table only when a task is DUE, and folds counters every `COUNTER_ROLLUP_INTERVAL_MS`. What makes
184
+ the trust safe is the fire statement's watermark fence, never the lease.
185
+ - **The worker and the relay back off** (`idle-backoff.ts`): floor → doubling → the ceiling, 2 s, or
186
+ 5 s while `wakeIsLive()`. An idle worker pass is ONE claim over every queue with a free slot.
187
+ - **The poll is the guarantee, the wake an optimisation.** `startQueueWake` holds one `LISTEN`
188
+ session and raises `enqueue-signal.ts`'s two signals; it is live only once a probe has crossed
189
+ BOTH channels, and un-proven on every re-dial — a transaction-pooling proxy resolves a `LISTEN`
190
+ and delivers nothing.
191
+ - **A statement decides whether it notifies** (`driver-pg-wake-sql.ts`): once per queue per
192
+ `WAKE_SLOT_MS` for an enqueue, never behind an unclaimed row for a stage. Every notifying commit
193
+ serialises behind ONE Postgres lock: never notify unconditionally. Payload: the queue name.
194
+ - **A wake resets the backoff**: the floor passes after it find the row a slot silenced.
195
+ - **The claim loop is ONE chain** (`worker-loop.ts`): a wake on a pass in flight sets `again`; a
196
+ freed slot and a due retry (`JobExecution.resumeAt`) `kick` one pass, no reset.
197
+ - **`idle-cost.test.ts` / `queue-wake.test.ts` count statements per idle minute** — scheduler 6,
198
+ worker and relay 30 with no wake, 12 with. Raising one is a regression.
199
+ - **The memory driver stores a payload's JSON form**, as pg binds it (`driver-settle-parity.test.ts`).
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`.
203
+
150
204
  ## Rules — the outbox
151
205
 
152
- - **The relay drains in two phases** (`accept` clears the interval; `close` awaits the pass under
153
- `settleAllBy`), its timer `unref`ed. **`relay.stop()` JOINS the pass in flight.**
154
- - **The claim is a LEASE in one statement** (`claimed_at`/`claimed_by` stamped in the locking CTE;
155
- outer `order by staged_at, id`). `OutboxStore.release` (optional) hands back a failed batch.
156
- `outbox-claim.test.ts` pins both stores.
157
- - **The lease is fenced on every mutation** (`and claimed_by = $n`; `OutboxRecord.claimedBy`);
158
- `markPublished` also requires `published_at is null`. A token-less call is unfenced in memory and
159
- fenced on this relay's id in pg (`outbox-pg.ts` says so). `id` (UUIDv7) is the tiebreak.
160
- - **`claimLeaseMs` is normalised in ONE place** (`outbox-lease.ts`, `resolveClaimLeaseMs`,
161
- `X_INVARIANT` at construction).
206
+ Long form: `docs/history/jobs.md`, "Moved 2026-10-01 — the outbox".
207
+
208
+ - **The relay drains in two phases**; `relay.stop()` JOINS the pass in flight, under a `DrainBudget`
209
+ a later shutdown binds LATE (the worker's shape); its timer is `unref`ed.
210
+ - **The claim is a LEASE in one statement**, fenced on every mutation (`and claimed_by = $n`);
211
+ `claimLeaseMs` is normalised in `outbox-lease.ts`. `outbox-claim.test.ts` pins both stores.
162
212
  - **The memory outbox store DELETES a published row** (`retained()` is the seam).
213
+ - **A staged row's id IS its job's id, and its `runId` is allocated at stage** (`x_outbox.run_id`),
214
+ so a staged enqueue answers real ids. `SQL_ENQUEUE` inserts nothing under an id that names a
215
+ row — a repeated publish is `deduped`, live or finished (`outbox-run-id.test.ts`).
163
216
 
164
217
  ## Rules — factories over `job()`
165
218
 
166
- - **`backfill()`**: a step persists the CURSOR and a count, never the page; step names are positional
167
- (`batch:<index>`), so `handle` gets no `step`; the iteration is rebuilt when `batches.cursor`
168
- disagrees with the checkpoint; a read-back checkpoint is checked. **`handle` is AT LEAST ONCE** (it
169
- runs before its checkpoint) — never invert. **A REPLAYED batch writes no ledger row.**
170
- - **A `backfill()` declaring `tenant: 'none'` gets the cross-tenant scope, and nothing else does**
171
- (`backfill-scope.ts`, `withBackfillScope`): only on the pass's own actor, for the pass's life;
172
- `runWithContext` outside `crossTenant`. `backfill-tenancy.test.ts` drives `executeJob`.
173
- - **`x_backfills` is what was SWEPT; the checkpoints are where a pass resumes**. Keyed by RUN; only
174
- `completed` blocks; `start()` clears `completed_at` on an adopted row. A moved checksum warns and
175
- does not run; `force` rides the input.
176
- - **The throttle is spent INSIDE the batch's `step.run`** (`backfill-rate.ts`, `createPacer` asserts
177
- its own rate).
178
- - **A backfill STAMPS its own handle** (`stampBackfill`, not exported); `registeredBackfills()` derives
179
- from `registeredJobs()`.
180
- - **The ledger says what RAN, the registry what EXISTS, `backfill-pending.ts` is the diff**;
181
- `isPendingBackfillState` is shared with `x db backfill --all`.
182
- - **`environments` is checked in `backfillPass()` (the rail) and `gateBackfill()` (a CLI pre-check);
183
- `requires` in `gateBackfill()` only; `count` in `backfillPass()` after the last batch**
184
- (`X_BACKFILL_STALLED`, its result parsed). `gateBackfill()` RETURNS its refusal.
185
- - **`inspectBackfills()` is the ONE projection of the ledger**, reads no clock, answers `[]` for a
186
- driver with none. The ledger hangs off `driver.backfills`, optional.
187
- - **`purge()`** is the one caller of every `purgeExpired()`: `PurgeTarget` is structural, `targets()` a
188
- thunk, one table per `step.run`, one clock reading for the whole pass, duplicate names refused
189
- (`X_INVARIANT`). `DEFAULT_PURGE_CRON`; `@ultimat3/cli`'s `dev-purge.ts` schedules it.
190
- - **`exportRows()`**: one object per PAGE, named by page index (a rerun rewrites the same bytes); the
191
- interleaving assertion in `export-pass.test.ts` is the memory guard; NO cross-tenant escape; the CSV
192
- formula guard is on strings only.
193
- - **`webhook()` delivers ONE event to ONE endpoint** (key `<name>:<endpointId>:<eventId>`); no steps
194
- (the endpoint carries the secret); the timestamp is SEND time; endpoint headers merge UNDER the
195
- framework's; `redirect: 'manual'`. `WebhookLedger` is a seam; every attempt is recorded before the
196
- throw. **The wire format is core's** (`packages/core/src/webhook-signature.ts`), re-exported.
219
+ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-01 — factories".
220
+
221
+ - **`backfill()`**: a step persists the CURSOR and a count, never the page; step names are positional;
222
+ **`handle` is AT LEAST ONCE** — never invert; a REPLAYED batch writes no ledger row. `tenant: 'none'`
223
+ is the only cross-tenant scope (`backfill-scope.ts`). `x_backfills` is what was SWEPT, keyed by RUN;
224
+ only `completed` blocks. The throttle is spent INSIDE the batch's `step.run`.
225
+ - **The ledger says what RAN, the registry what EXISTS, `backfill-pending.ts` is the diff.**
226
+ `environments` is checked in `backfillPass()` and `gateBackfill()`; `requires` in `gateBackfill()`
227
+ only; `count` after the last batch (`X_BACKFILL_STALLED`). `inspectBackfills()` is the ONE projection.
228
+ - **`purge()`** is the one caller of every `purgeExpired()`: one table per `step.run`, one clock
229
+ reading per pass, duplicate names refused.
230
+ - **`exportRows()`**: one object per PAGE, named by page index; NO cross-tenant escape.
231
+ - **`webhook()`** delivers ONE event to ONE endpoint; no steps; every attempt recorded before the
232
+ throw. **The wire format is core's**, re-exported.
197
233
  - A `-fixture.ts` file does not ship; `backfill-pass-fixture.ts` raises a plain `Error` subclass on
198
234
  purpose (it stands in for app code).
199
235
 
200
- ## Known coupling
201
-
202
- `driver-pg.ts`'s `PgExecutor` is a one-method structural interface (no `@ultimat3/db` dependency).
203
- `packages/cli/src/dev-queue.ts` wraps a real `@ultimat3/db` client, so queue statements pass
204
- `@ultimat3/db`'s statement observer with no `{entity, op}` attribution — future work, see
205
- `packages/db/CLAUDE.md`'s `observe.ts` section.
206
-
207
236
  ## Files
208
237
 
209
238
  | File | Owns |
@@ -212,60 +241,72 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
212
241
  | `tenant.ts` | what tenant a run acts under: `JobTenant`, the declaration's backstop, the actor `executeJob` installs |
213
242
  | `backfill.ts` | `backfill()` — a factory over `job()`: the declaration, its checksum and its input |
214
243
  | `backfill-pass.ts` | one pass: the batched iteration, its cursor checkpoints and its ledger row |
215
- | `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) |
216
245
  | `backfill-ledger.ts` | `x_backfills` — the contract, `BACKFILL_STATUSES`, the checksum, the verdict, the memory ledger |
217
246
  | `backfill-registry.ts` | what was DECLARED: the `origin` stamp, `isBackfill`, `registeredBackfills` |
218
247
  | `backfill-gate.ts` | may this sweep run here and now — environment, `requires`, already-applied |
219
248
  | `backfill-pending.ts` | declared minus completed, per environment: the alarm `--pending` reads |
220
249
  | `backfill-rate.ts` | the `rate` throttle: batches/sec as an interval, and the cancellable wait |
221
250
  | `backfill-inspect.ts` | the ledger projected for `x db backfill`, `x jobs`, `/_x` and MCP |
222
- | `backfill-errors.ts` | the seven `X_BACKFILL_*` classes — split out of `errors.ts`, which was over the 500-line ceiling. The codes themselves stay declared in `errors.ts`: one registry, one place |
223
- | `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements. Skips a non-job in silence — a module namespace is full of helpers — EXCEPT an `@ultimat3/action` projection (`kind: 'action-job'`), which is `X_ACTION_JOB_UNBRIDGED` |
251
+ | `backfill-errors.ts` | the seven `X_BACKFILL_*` classes; the codes stay declared in `errors.ts` |
252
+ | `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements; an `action-job` projection is `X_ACTION_JOB_UNBRIDGED` |
224
253
  | `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
225
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 |
226
256
  | `outbox.ts` | staging in a `Tx`, the store seam, the ambient `JobsFacade` slot |
227
- | `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks. Split off at `outbox.ts`'s 500-line ceiling |
257
+ | `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks |
228
258
  | `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
229
259
  | `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
230
260
  | `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
261
+ | `concurrency.ts` | `job.concurrency` as declared, resolved once: `KeyedConcurrency`, `WhenBusy`, the declaration and key refusals |
262
+ | `errors-concurrency.ts` | every concurrency refusal's class; codes stay in `errors.ts` |
263
+ | `worker-key-busy.ts` | one run refused by its key: settled `failed`, body never run |
231
264
  | `metrics.ts` | `queue_oldest_ready_seconds` and `queue_dead_jobs`, the two alertable gauges |
232
- | `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark) + `createPgLeaseLeader` |
265
+ | `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark, the atomic fire) + `createPgLeaseLeader` |
233
266
  | `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
234
267
  | `driver.ts` | `JobDriver` contract + wire records |
235
- | `driver-pg.ts` | default driver, real SQL constants, and `createPgLeader` — the advisory-lock election that is **not** what a scheduler uses; `scheduler-pg.ts` above owns the lease-row one boot wires |
236
- | `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` — the schema the driver installs, and the ONE install point: every durable table this package owns, `x_outbox` included, is declared in it. Whichever file holds the DDL is the one whose comments may carry no `;` and no `'` |
237
- | `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and the `JOB_ROW_COLUMNS` projection they share. Split off at `driver-pg-sql.ts`'s size ceiling and re-exported from it |
268
+ | `driver-pg.ts` | default driver, and `createPgLeader` |
269
+ | `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` — the ONE install point. Its comments carry no `;` and no `'` |
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` |
238
271
  | `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
239
- | `driver-memory.ts` | `x dev` / tests |
272
+ | `driver-memory.ts` / `claim-exhausted.ts` | `x dev` / tests; a buried row's log line and `onSettled` |
240
273
  | `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
241
274
  | `retry.ts` | the dead-letter decision, and this package's option names over core's `backoffDelay` — no curve of its own |
242
- | `retry-classification.ts` | the OTHER half of that decision: what the thrown error says, and the stop reason the row and the log carry |
243
- | `execute.ts` | `executeJob` — one claimed job run and settled, and the run's deadline/cancel |
275
+ | `retry-classification.ts` | the other half: what the thrown error says, and the stop reason |
276
+ | `execute.ts` / `run-deadline.ts` | `executeJob` — one claimed job run and settled; the run's deadline: cancel, then fail |
244
277
  | `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
245
- | `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
246
- | `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 |
280
+ | `worker-admit.ts` | may this claimed job start: the limiter, the fleet slot, and the shed / refusal |
281
+ | `worker-registry.ts` | one worker's registry row: announce, heartbeat, forget |
247
282
  | `worker-types.ts` | the worker's public contract: `WorkerOptions`, `WorkerStats`, `Worker` |
248
- | `drain-wait.ts` | the drain's wait, shared by both roles: everything a teardown holds, settled — or abandoned at the budget the `close` hook was handed |
249
- | `worker-run.ts` | one claimed job, wired: its heartbeat, its slot renewal, its run signal and its span, started together and handed back in one `finally` |
250
- | `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
251
- | `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back. The claim loop asks "may I start this one?"; this answers it across the fleet |
252
- | `purge.ts` | `purge()` — a factory over `job()`: the retention sweep, its structural target seam and the hourly cron a host schedules it on |
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 |
286
+ | `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back — and the boot refusal |
287
+ | `purge.ts` | `purge()` — a factory over `job()`: the retention sweep and its target seam |
253
288
  | `task.ts` | the `task()` primitive + registry + the handle's surface + `registerTask` |
254
289
  | `scheduler.ts` | `scheduler` role: the dispatch round, catch-up, leader election, the drain |
255
290
  | `limits.ts` | per-tenant / per-queue / global concurrency + rate |
256
291
  | `events.ts` | stored event bus for `step.waitForEvent` |
257
292
  | `inspect.ts` | `--json` introspection |
293
+ | `inspect-operator.ts` | the verbs `x jobs` binds: remove, promote, pause, resume |
294
+ | `introspection.ts` | `JobIntrospection`, every operator type, and the bounds |
295
+ | `counters.ts` | bucket arithmetic, `nackOutcome`, the memory counters |
296
+ | `driver-memory-operator.ts` / `driver-pg-operator.ts` | each driver's operator members |
297
+ | `driver-pg-operator-sql.ts` / `driver-pg-settle-sql.ts` | their statements; `SQL_ACK` / `SQL_NACK` with the counter |
298
+ | `progress.ts` / `settled.ts` / `redact-input.ts` | the progress throttle; the `onSettled` runner; a payload as an operator reads it |
299
+ | `scheduler-state.ts` | `SchedulerState`, `fire`, the memory watermark |
300
+ | `scheduler-leader.ts` / `scheduler-occurrences.ts` | `LeaderElection` + `soleLeader`; which occurrences fall in a window |
301
+ | `idle-backoff.ts` / `enqueue-signal.ts` | the wait both polling loops share; the two wake signals and `wakeIsLive` |
302
+ | `queue-wake.ts` / `driver-pg-wake-sql.ts` | the `LISTEN` session and its proof; which statement notifies |
303
+ | `worker-loop.ts` | the claim loop's timer: wake, `again`, the kicks |
304
+ | `driver-pg-outbox-sql.ts` | the `x_outbox` statements |
305
+ | `errors-operator.ts` / `errors-requeue.ts` | the operator verbs' refusals; `requeue`'s |
258
306
 
259
- `backfill-pass-fixture.ts` is the one harness `backfill-pass.test.ts` and
260
- `backfill-pass-ledger.test.ts` share. `*.job.test.ts` is the opt-in `job` step: `replay`,
261
- `idempotency` and `outbox-atomicity` each prove one guarantee through a REAL worker, and
262
- `worker-soak.job.test.ts` kills one worker mid-job and asserts exactly-once completion.
263
-
264
- ## Commands
307
+ Which `-fixture.ts` harness each suite shares, and what each `.job.` suite proves:
308
+ `docs/history/jobs.md`, "Moved 2026-10-01 — test harnesses".
265
309
 
266
- ```
267
- bun test packages/jobs
268
- bun run --filter @ultimat3/jobs typecheck
269
- ```
310
+ Commands: `bun test packages/jobs` · `bun run --filter @ultimat3/jobs typecheck`.
270
311
 
271
312
  Why each rule above is shaped the way it is: [`docs/history/jobs.md`](../../docs/history/jobs.md).