@ultimat3/jobs 1.2.0 → 3.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 (47) hide show
  1. package/CLAUDE.md +660 -0
  2. package/README.md +432 -17
  3. package/package.json +7 -5
  4. package/src/backfill-gate.ts +97 -0
  5. package/src/backfill-inspect.ts +73 -0
  6. package/src/backfill-ledger.ts +183 -0
  7. package/src/backfill-pass.ts +276 -0
  8. package/src/backfill-pending.ts +131 -0
  9. package/src/backfill-rate.ts +109 -0
  10. package/src/backfill-registry.ts +108 -0
  11. package/src/backfill-scope.ts +70 -0
  12. package/src/backfill.ts +213 -0
  13. package/src/driver-memory.ts +61 -9
  14. package/src/driver-nats.ts +2 -1
  15. package/src/driver-pg-ddl.ts +191 -0
  16. package/src/driver-pg-rows.ts +123 -0
  17. package/src/driver-pg-sql.ts +312 -55
  18. package/src/driver-pg.ts +138 -92
  19. package/src/driver-redis.ts +2 -1
  20. package/src/driver.ts +91 -7
  21. package/src/errors.ts +314 -5
  22. package/src/events-pg.ts +121 -0
  23. package/src/events.ts +7 -1
  24. package/src/execute.ts +308 -0
  25. package/src/heartbeat.ts +148 -0
  26. package/src/index.ts +128 -27
  27. package/src/inspect.ts +43 -2
  28. package/src/job.ts +127 -3
  29. package/src/leases.ts +90 -0
  30. package/src/limits.ts +0 -0
  31. package/src/metrics.ts +35 -0
  32. package/src/outbox-lease.ts +29 -0
  33. package/src/outbox-pg.ts +188 -0
  34. package/src/outbox.ts +204 -59
  35. package/src/register.ts +1 -1
  36. package/src/renewal-timer.ts +35 -0
  37. package/src/retry-classification.ts +112 -0
  38. package/src/retry.ts +6 -1
  39. package/src/run-signal.ts +50 -0
  40. package/src/scheduler-pg.ts +103 -0
  41. package/src/scheduler.ts +159 -245
  42. package/src/steps.ts +155 -31
  43. package/src/task.ts +239 -0
  44. package/src/tenant.ts +61 -0
  45. package/src/worker-fleet-slots.ts +129 -0
  46. package/src/worker-run.ts +132 -0
  47. package/src/worker.ts +207 -190
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @ultimat3/jobs ⚙️
2
2
 
3
- Durable background work. Steps that replay, a transactional outbox that is on by default,
4
- and one driver interface so Postgres → Redis → NATS is a config line.
3
+ Durable background work. Steps that replay, a transactional outbox, and one driver interface so
4
+ a job's code never names a backend.
5
5
 
6
6
  ```ts
7
7
  import { job, t } from '@ultimat3/jobs';
@@ -9,6 +9,7 @@ import { job, t } from '@ultimat3/jobs';
9
9
  export const onboardOrg = job({
10
10
  input: t.object({ orgId: t.uuid }),
11
11
  idempotencyKey: ({ orgId }) => `onboard:${orgId}`, // REQUIRED by the type
12
+ tenant: ({ orgId }) => orgId, // REQUIRED by the type — or tenant: 'none'
12
13
  retry: { attempts: 5, backoff: 'exponential' },
13
14
  async run({ input, step, ctx }) {
14
15
  const org = await step.run('provision', () => ctx.orgs.provision(input.orgId));
@@ -90,11 +91,37 @@ usually "nobody thought about it", and the bug — two charges, two welcome emai
90
91
  provisioned orgs — shows up in production under load and never in a test. There is no way
91
92
  to define a job in Ultimate that cannot be deduped.
92
93
 
94
+ ## `tenant` is required by the type
95
+
96
+ The same shape, for the same class of bug. A job body runs with no request behind it, so nothing
97
+ can read the acting org off a caller — and `@ultimat3/entity`'s tenant guard derives from the
98
+ **ambient** context, so a job that declared nothing used to run with no tenant at all: a row naming
99
+ another org was refused over HTTP as `X_TENANCY_ACTOR_MISMATCH` and accepted through the queue.
100
+
101
+ ```ts
102
+ tenant: (input) => input.orgId // the run acts under this org
103
+ tenant: 'none' // this job belongs to no tenant
104
+ ```
105
+
106
+ `executeJob` derives the org, puts it on the run's actor and installs that context, so
107
+ `ctx.actor.orgId` inside the body is the tenant the job declared — and every tenant-scoped read and
108
+ write is checked against it, exactly as it is on every other surface. `'none'` carries **no** org,
109
+ so a tenant-scoped read inside such a `job()` is `X_TENANCY_ACTOR_ORG_REQUIRED`: a job body that
110
+ genuinely spans tenants says so with `crossTenant(reason, fn)` around its own reads. (A
111
+ `backfill()` is the one exception, and it is not a loophole — its `source` is a lazy chain, so the
112
+ author has nothing to wrap and the pass opens the scope itself. See the backfill section below.)
113
+ Omitting the field is a type error, and
114
+ `X_JOB_TENANT_REQUIRED` for generated code and JS callers.
115
+
116
+ A single boot-supplied service actor would have closed the same hole with one identity for every
117
+ job in the app — which is a cross-tenant read waiting for the first job that takes an org id in its
118
+ input. The tenant is a fact about the work, so the job declares it.
119
+
93
120
  ## Durable steps
94
121
 
95
122
  | Call | Behaviour |
96
123
  |---|---|
97
- | `step.run(name, fn)` | runs once ever; result persisted before the next step starts |
124
+ | `step.run(name, fn)` | runs once ever; result persisted before the next step starts. `fn` receives an `AbortSignal` |
98
125
  | `step.sleep(name, '3d')` | suspends the run, requeues it for the wake time |
99
126
  | `step.sleep('3d')` | same, step name derived from the duration |
100
127
  | `step.waitForEvent(name, event, { match, timeout })` | suspends until `publishEvent()` matches |
@@ -103,12 +130,211 @@ Step names are the replay key, so they must be deterministic and unique in a run
103
130
  duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
104
131
  (`StepSuspension`), never a failure: it does not burn a retry attempt.
105
132
 
106
- ## The transactional outbox (on by default)
133
+ ## Backfills are jobs
134
+
135
+ `backfill()` is a **factory over `job()`**, not a ninth primitive — one pass over every row a
136
+ chain matches, with the retry policy, the queue, the cancellation and the dead-letter path a job
137
+ already has.
138
+
139
+ ```ts
140
+ import { backfill } from '@ultimat3/jobs';
141
+ import { db } from '@postly/db';
142
+
143
+ export const rewriteSlugs = backfill({
144
+ name: 'rewrite-slugs', // REQUIRED: a durable key
145
+ batch: 1_000, // rows per statement and per step
146
+ rate: 5, // batches/sec — the default
147
+ tenant: 'none', // every tenant — the pass scopes itself
148
+ source: () => db.posts.where({ published: true }),
149
+ async handle({ rows }) {
150
+ await db.posts.upsertAll(rows.map(slugged), { onConflict: ['id'] });
151
+ },
152
+ });
153
+
154
+ await rewriteSlugs.enqueue({}); // it is a JobHandle
155
+ ```
156
+
157
+ The source is read through `inBatches()` — one statement per page, keyset, never OFFSET — and
158
+ each page is handled inside its own `step.run`, named `batch:0`, `batch:1`, … A run killed
159
+ mid-pass therefore **resumes on the page it stopped at**: completed steps replay from storage
160
+ without a statement, and the iteration reopens at the cursor they left behind.
161
+
162
+ | Rule | Why |
163
+ |---|---|
164
+ | the checkpoint is a cursor and a count, never the page | a completed step's output is retained for the whole run — checkpointing rows would hold every processed row until the job ends |
165
+ | `handle` is given no `step` | a step name minted inside it collides with itself on batch 2 (`X_STEP_DUPLICATE`) |
166
+ | `handle` is given a `signal` | the run's deadline composed with the batch's own ceiling — hand it to whatever the body calls |
167
+ | `handle` runs at least once per page | an attempt cancelled between the last row and the checkpoint replays it — write through `upsertAll` / `updateWhere`, never `count + 1` |
168
+ | `idempotencyKey` is the backfill's name | re-enqueueing a live pass is the same pass, not a second writer on one table |
169
+ | `tenant` is required, exactly as on `job()` | a backfill IS a job. `tenant: () => orgId` scopes every page to one org; `tenant: 'none'` sweeps every tenant, and the PASS opens that cross-tenant scope — `source` is a lazy chain, so the author has nothing to wrap |
170
+ | `batch` is refused at declaration | `0`, `1.5` and a `NaN` from an env var fail the build, not the fourth attempt |
171
+ | `rate` throttles, and there is no way off | a sweep shares its pool with the requests the app is still serving; to go faster raise the number |
172
+ | the pause is spent **inside** the step | a resumed attempt replays 500 checkpoints and re-pays none of their pauses |
173
+
174
+ ### The throttle
175
+
176
+ `rate` is batches per second, defaulting to `DEFAULT_BACKFILL_RATE` (5) — 5,000 rows/sec at the
177
+ default batch, one statement every 200ms, so the pool spends the rest of each interval on the
178
+ app. A rate above what the batches can actually achieve produces no wait, which is why there is
179
+ no unthrottled mode to reach for: `rate: 200` is the fast sweep. Fractions are rates too
180
+ (`rate: 0.5` is one batch every two seconds), a rate that is not finite and positive is refused
181
+ where it was written, and the wait unwinds on the run's cancellation (`X_ABORTED`) rather than
182
+ sitting in a timer nobody is waiting for. `rate` is **not** part of the definition checksum, for
183
+ the reason `batch` is not: pacing is tuning, and changing it does not make a completed sweep a
184
+ different sweep.
185
+
186
+ ### The `x_backfills` ledger
187
+
188
+ What has already been swept, the twin of `x_migrations`. It ships in the same DDL as `x_jobs`,
189
+ hangs off the queue driver as `driver.backfills`, and carries one row per **pass**: name,
190
+ definition checksum, status, app version, rows processed, last cursor, started/completed.
191
+
192
+ ```ts
193
+ // The PASS is the no-op, never the enqueue. The one-live-run index covers `ready`/`delayed`/
194
+ // `running`/`suspended`, and a completed job is in none of them — so this creates a real job row,
195
+ // a worker runs it, it reads the ledger and reports what it found.
196
+ const again = await rewriteSlugs.enqueue({}); // → { deduped: false, … }
197
+ // the run's own result:
198
+ // → { name: 'rewrite-slugs', batches: 0, rows: 0, skipped: true, previousRunId: '…' }
199
+
200
+ await rewriteSlugs.enqueue({ force: true }); // sweeps again, into a NEW row
201
+ ```
202
+
203
+ | Rule | Why |
204
+ |---|---|
205
+ | only a **completed** row blocks | a `running` row is this pass resuming, a `failed` one is an attempt the queue is about to retry |
206
+ | `force` writes a new row | reruns are history, never an edit of the row they rerun |
207
+ | a moved checksum **warns** | it hashes function source text, which a bundler moves without behaviour changing — `@ultimat3/db` throws on the same fact because SQL text is what it applied |
208
+ | the row is a report, never a resume source | where a resumed pass restarts is the step checkpoints' answer, and there is only one |
209
+ | a retry adopts its own row | `started_at` is when the pass began, not when this attempt did |
210
+ | a driver without a ledger runs the pass anyway | the same degradation `introspect` has — no bookkeeping, never a refusal |
211
+
212
+ Three surfaces read it, all through one projection (`inspectBackfills()`), so none of them can
213
+ report a different number:
214
+
215
+ | Surface | Shows |
216
+ |---|---|
217
+ | `x db backfill --list` | the whole ledger, `--name` / `--status` / `--limit`, and `--json` |
218
+ | `x jobs ls` | the passes **in flight** — rows so far and cursor, beside the queue depth |
219
+ | `x jobs show <id>` | the ledger row for that run, under `backfill` (`null` for any other job) |
220
+ | `/_x` → jobs | the whole ledger plus a live count, alongside the queues and the step traces |
221
+
222
+ ### What was DECLARED — the other half of the ledger
223
+
224
+ `As of 2026-08`.
225
+
226
+ The ledger says which passes have run. `registeredBackfills()` says which ones **exist**, and the
227
+ diff between them is the alarm: a sweep merged, deployed and never enqueued had no row and showed
228
+ up on no surface at all.
229
+
230
+ ```ts
231
+ import { isBackfill, pendingBackfills, registeredBackfills } from '@ultimat3/jobs';
232
+
233
+ registeredBackfills(); // [{ kind: 'backfill', name, checksum, requires, environments, counts }]
234
+ isBackfill(rewriteSlugs); // true — a plain job() is false, and so is a look-alike
235
+
236
+ pendingBackfills({ declarations, runs, environment });
237
+ // → { environment, rows, pending, orphaned }
238
+ ```
239
+
240
+ `backfill()` stamps its own handle through the `origin` WeakMap `task()` already uses — an app
241
+ registers nothing, because a declaration that has to be registered a second time is a declaration
242
+ half the apps will forget. `x db backfill --pending` is the command, and it exits **non-zero** when
243
+ anything is unswept so a cron or a deploy check can read the exit code alone.
244
+
245
+ | State | Means | In `pending` |
246
+ |---|---|---|
247
+ | `pending` | declared, no row under this name | yes |
248
+ | `failed` | the newest pass failed — the queue may have dead-lettered it | yes |
249
+ | `running` | a pass is in flight | no — a check red for the whole of every sweep gets muted |
250
+ | `completed` | a completed row exists, so nothing re-runs without `--force` | no |
251
+ | `excluded` | `environments` does not include this one | no |
252
+
253
+ ### Three optional declarations, all of them DATA
254
+
255
+ ```ts
256
+ export const dropLegacy = backfill({
257
+ name: 'drop-legacy',
258
+ tenant: 'none', // required, exactly as on job() — see the table above
259
+ requires: '20260814120000_add_publish_at', // a migration id, checked against x_migrations
260
+ environments: ['staging', 'production'], // omitted = every environment
261
+ count: ({ ctx }) => db.posts.where({ publishedAt: null }).count(),
262
+ source: ({ ctx }) => db.posts.where({ publishedAt: null }),
263
+ handle: async ({ rows }) => { /* … */ },
264
+ });
265
+ ```
266
+
267
+ | Field | Rail | Enforced where |
268
+ |---|---|---|
269
+ | `requires` | `X_BACKFILL_MIGRATION_PENDING` — the migration is not applied | `x db backfill`, which is where `x_migrations` is readable; this package holds no `@ultimat3/db` dependency and will not grow one to read a ledger |
270
+ | `environments` | `X_BACKFILL_ENVIRONMENT` | **the pass** (`backfillPass`) — the rail, because `.enqueue()` from app code reaches no command — and again in `gateBackfill()` as the CLI's pre-check, so `x db backfill` refuses before it queues work that would only dead-letter |
271
+ | `count` | `X_BACKFILL_STALLED` — the source ran out and this still matches rows | the pass, once, after the last batch |
272
+
273
+ `environments` ships as declared data and never as a hardcoded "cleanups are production": a staging
274
+ rehearsal is correct practice, so which deploys a sweep belongs to is the app's convention and this
275
+ is only the mechanism carrying it. There is deliberately **no** `dependsOn` graph over other
276
+ backfills — the real dependency is almost always "after code tolerating both shapes is serving",
277
+ which the framework cannot observe.
278
+
279
+ `count` is the same predicate `source` selects on, counted. It is what makes a dry run honest and
280
+ "did it converge" arithmetic: a pass that exhausts its source while `count()` still answers above
281
+ zero has two predicates that disagree, which is an authoring bug and not something a retry fixes.
282
+ Its result is parsed rather than trusted — a non-negative safe integer or `X_INVARIANT`, because
283
+ `NaN > 0` and `-1 > 0` are both false and would complete the sweep the detector exists to fail.
284
+
285
+ ### Running one
286
+
287
+ ```
288
+ x db backfill --pending --json # declared minus completed; non-zero when anything is unswept
289
+ x db backfill drop-legacy # DRY RUN — --write is never implied
290
+ x db backfill drop-legacy --write # gate, then enqueue; the workers sweep
291
+ x db backfill --all --write # every pending one, isolated per name
292
+ x db backfill drop-legacy --write --force # a completed name, again, into a NEW ledger row
293
+ ```
294
+
295
+ `--write` **enqueues**; it never runs the pass inline, because the queue is a job's execution
296
+ surface. That is what makes the `backfill` deploy role a trigger rather than a gate: it runs after
297
+ the new pods serve, puts the sweeps on the queue and exits, and a slow UPDATE never holds a release
298
+ open against a database still serving the previous build. `--all` isolates per name and continues
299
+ past a failure, so one wedged cleanup cannot block every later one forever.
300
+
301
+ ## The deadline cancels
302
+
303
+ A job's `timeout` aborts `ctx.signal` **before** it fails the attempt, because the nack that
304
+ follows makes the job claimable by another worker — a body still running past it is a second
305
+ copy of one job.
306
+
307
+ ```ts
308
+ run: async ({ input, ctx, step }) => {
309
+ const res = await fetch(url, { signal: ctx.signal }); // stops at the deadline
310
+ await step.run('save', (signal) => save(res, { signal })); // the step's own ceiling too
311
+ },
312
+ ```
313
+
314
+ Nothing can kill a body that ignores the signal, so the durable state is fenced: past the
315
+ cancel every step write is refused with `X_ABORTED`, and a run that finishes anyway is logged
316
+ as `jobs.timeout.abandoned` — the one way to find a handler that never reads `ctx.signal`.
317
+
318
+ Three ceilings, declared on the job and nowhere else (`As of 2026-08` — `stepTimeout` and
319
+ `eventPoll` had been implemented in the step runner since 1.0 with no declaration able to reach
320
+ them, so no `job()` could ask for either):
321
+
322
+ | Field | Bounds | Absent |
323
+ |---|---|---|
324
+ | `timeout` | the whole attempt — aborts `ctx.signal`, then fails it | no attempt deadline |
325
+ | `stepTimeout` | ONE `step.run` — aborts that step's signal, then fails the step | no per-step ceiling |
326
+ | `eventPoll` | how long a `step.waitForEvent` parks between polls | 30s |
327
+
328
+ A zero or negative `stepTimeout` / `eventPoll` is refused at declaration, the way `concurrency: 0`
329
+ is: `withStepTimeout` reads `<= 0` as "no ceiling at all", which is the opposite of what the author
330
+ wrote.
331
+
332
+ ## The transactional outbox
107
333
 
108
334
  ```ts
109
335
  await ctx.tx(async (tx) => {
110
336
  const post = await ctx.posts.publish(input.postId, tx);
111
- await notifySubscribers.enqueue({ postId: post.id }); // joins `tx`
337
+ await notifySubscribers.enqueue({ postId: post.id, orgId: input.orgId }); // joins `tx`
112
338
  });
113
339
  ```
114
340
 
@@ -122,18 +348,80 @@ it after commit. The bug class this removes:
122
348
 
123
349
  Both are load-dependent, both pass every test you would write, and both produce "the email
124
350
  went out but the order isn't in the database". Joining the transaction closes the window.
125
- The relay publishes *then* marks published, so a crash re-publishes — collapsed by the
126
- idempotency key. Set `mode: 'required'` to make an enqueue outside a transaction an
127
- `X_OUTBOX_NO_TX` error instead of a direct publish.
351
+ The relay publishes *then* marks published, so a crash re-publishes — and **that repeat is
352
+ collapsed only while the first job is still live** (`As of 2026-08`): `SQL_ENQUEUE`'s conflict
353
+ target is a partial index over `ready`/`delayed`/`running`/`suspended`, so a re-publish landing
354
+ after the first job reached a terminal state inserts a second row and the handler runs again.
355
+ **Handlers are at-least-once. Write them idempotent** — that is the standing contract, not a
356
+ caveat on this paragraph. A publish that FAILS stops the batch rather than letting later rows
357
+ overtake it: `claim()` returns rows in `staged_at, id` order — total, so two relays compose the
358
+ same batch in the same order — and an app that stages `createInvoice` then `chargeCard` in one
359
+ transaction must never have the charge run first. Set `mode: 'required'` to
360
+ make an enqueue outside a transaction an `X_OUTBOX_NO_TX` error instead of a direct publish.
361
+
362
+ **It is not on by default, and it is not on until you install it** (`As of 2026-08`). Three
363
+ things have to be true in a process:
364
+
365
+ | Step | Call |
366
+ |---|---|
367
+ | the table exists | ships in `SQL_JOBS_TABLE` — applying the queue DDL is enough |
368
+ | the facade is installed | `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` |
369
+ | the relay is running | `createOutboxRelay({ store, driver }).start()` |
370
+
371
+ with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
372
+ transactional: `stage()` runs on the CALLER'S connection, never the pool. With nothing installed,
373
+ `jobsFacade()` answers a fallback whose `currentTx` is `() => undefined` and every enqueue
374
+ publishes straight to the driver — deliberate, so a script and a test enqueue with no wiring, but
375
+ it is a fallback and not the guarantee.
376
+
377
+ `claim()` is a **claim, not a read** (`As of 2026-08`). `for update skip locked` in a bare select
378
+ holds its row locks only until that statement ends — under autocommit, before `claim()` even
379
+ resolves — so two relays polling 200ms apart read the same unpublished rows and both publish them.
380
+ `SQL_ENQUEUE` collapses that repeat only while the first job is still LIVE, because its conflict
381
+ target is a partial index over the live states: a second publish landing after that job finished
382
+ inserts a second row and **the handler runs twice**. So the claim stamps `claimed_at` in the same
383
+ statement that locks the row, and that stamp is a lease — `claimLeaseMs` (a positive whole number
384
+ of ms, default 30s; anything else is `X_INVARIANT` at construction) is how long the rows of a relay
385
+ that DIED mid-batch wait before any relay may take them again. A batch a failed publish stopped is
386
+ handed back at once through `release`, so a pool blip still costs one poll interval and not a lease
387
+ window.
388
+
389
+ **Every outbox mutation is fenced on the claimant, not just the claim** (`As of 2026-08`).
390
+ `release` and `markPublished` both match on `claimed_by`, and `claim()` hands the token back on
391
+ each record as `claimedBy`. A relay that stalls past its lease wakes up owning nothing: its late
392
+ `release` would otherwise unclaim rows the relay that reclaimed them is mid-publish on (a third
393
+ relay claims and republishes them), and its late `markPublished` would retire a row nobody has
394
+ published yet — losing the job outright. Both are no-ops now, in the pg store and in the memory
395
+ store alike.
396
+
397
+ What the lease buys, precisely:
398
+
399
+ | It stops | It does not stop |
400
+ |---|---|
401
+ | two relays holding one batch — a committed row cannot be claimed twice inside its lease | the handler running twice |
402
+ | a lapsed claimant releasing or retiring a newer claimant's rows | a crash between publish and `markPublished` re-publishing after the first job is terminal |
403
+ | a relay that died mid-batch stranding its rows forever | anything a **non-idempotent** handler does on its second run |
404
+
405
+ The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
406
+ `retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
407
+ the audit trail this map is not. A relay pass that throws is logged as `jobs.outbox.tick-failed`
408
+ and the loop re-arms: an unobserved rejection would end the process with rows still staged.
409
+
410
+ `relay.stop()` is **async and joins the pass in flight** — `await` it before closing the database,
411
+ the way `worker.stop()` and `scheduler.stop()` are awaited. A pass is a publish followed by a
412
+ `markPublished`, and a caller that returned between the two closed the pool under the row it was
413
+ about to mark.
128
414
 
129
415
  ## Drivers
130
416
 
131
417
  One interface: `enqueue`, `claim` (visibility timeout), `ack`, `nack` (backoff),
132
- `heartbeat`, `stats`. Zero job-code change between them.
418
+ `heartbeat`, `stats`, plus optional `introspect`, `backfills` and `leases`. Zero job-code change
419
+ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driver` config line**:
420
+ `JobsConfig.driver` has no reader and boot always builds `createPgDriver`.
133
421
 
134
422
  | Driver | Status | Backing | Use |
135
423
  |---|---|---|---|
136
- | `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, partial unique index, advisory-lock leader | zero-infra start, most apps |
424
+ | `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, a partial unique index on `(name, idempotency_key)`, lease-based leader, `x_job_leases` | zero-infra start, most apps |
137
425
  | `memory` | complete | in-process maps | `x dev`, tests |
138
426
  | `redis` | interface-complete, `X_NOT_IMPLEMENTED` | Streams + consumer groups | planned |
139
427
  | `nats` | interface-complete, `X_NOT_IMPLEMENTED` | JetStream work queue | planned |
@@ -145,8 +433,32 @@ debugging a stuck queue can read and run the exact statement.
145
433
 
146
434
  | Role | Entry | Behaviour |
147
435
  |---|---|---|
148
- | `worker` | `createWorker({ driver, queues, concurrency })` | per-queue pools, heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
149
- | `scheduler` | `createScheduler({ driver, leader })` | advisory-lock leader, one dispatcher per tick, catch-up policy |
436
+ | `worker` | `createWorker({ driver, context, queues, concurrency })` | per-queue pools, lease heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
437
+ | `scheduler` | `createScheduler({ driver, leader, state })` | one dispatch round at a time, catch-up policy, SIGTERM drain: stop dispatching → finish the round → release the lock |
438
+
439
+ `driver` and `context` are the two required keys on `WorkerOptions`; everything else defaults.
440
+ `context: () => Ctx` supplies the ambient `Ctx` a job run executes under — the app wires its ALS
441
+ and its tenant there, and a worker with no way to build one would run every handler as nobody.
442
+
443
+ **Pass `state` and `leader` in any real deployment.** The defaults are a `Map` and "always the
444
+ leader", and both fail silently: with no durable watermark a redeployed scheduler arms to tomorrow
445
+ and never detects the occurrence the pod it replaced dropped (`catchUp` and `maxCatchUp` are inert
446
+ — "missed" is relative to a watermark that no longer exists), and with no election a rolling
447
+ update runs two leaders.
448
+
449
+ ```ts
450
+ createScheduler({
451
+ driver,
452
+ state: pgSchedulerState(executor),
453
+ leader: createPgLeaseLeader({ executor }),
454
+ });
455
+ ```
456
+
457
+ `createPgLeaseLeader` and not `createPgLeader`: `pg_try_advisory_lock` is scoped to a Postgres
458
+ *session*, and the executor this package is handed is a **pool** — the lock is released the moment
459
+ that connection goes back to it, so every node reads itself as leader. The lease is a row with an
460
+ expiry and needs no connection affinity. `acquire()` is also the renewal, called every round, which
461
+ is how a demoted node finds out.
150
462
 
151
463
  ```ts
152
464
  export const nightlyDigest = task({
@@ -159,7 +471,17 @@ export const nightlyDigest = task({
159
471
  `tz` is required by the type *and* validated against the runtime's IANA database, because a
160
472
  non-empty string is not a timezone: `tz: 'Bogota'` would resolve every occurrence in UTC and
161
473
  run five hours off, silently, forever. `0 3 * * *` in a DST zone runs twice or zero times on
162
- the switch day. Catch-up after downtime is explicit: `skip` (default), `run-once`, `run-all`.
474
+ the switch day. Catch-up after downtime is explicit: `skip` (default) fires the latest missed
475
+ occurrence and drops the older ones, `run-once` fires the earliest missed one, `run-all` fires
476
+ every one of them. `maxCatchUp` (default 10) bounds the WALK for every mode, not just `run-all`:
477
+ one tick walks at most that many occurrences forward from the last fire, and the policy then
478
+ picks from what that walk found — so after a long outage `skip` fires the latest occurrence
479
+ *within the cap*, not the true latest missed.
480
+
481
+ `run-once` fires **once**, not once per tick. Dropping the rest means the watermark passes them
482
+ too, so a scheduler back up after a day down enqueues one catch-up and then waits for the next
483
+ real occurrence. It used to leave the watermark on the occurrence it had just run, which made an
484
+ hourly task fire 24 catch-ups a second apart.
163
485
 
164
486
  ## Retries
165
487
 
@@ -172,16 +494,104 @@ retrySchedule({ attempts: 5, backoff: 'exponential', delay: 1000 })
172
494
  // => [1000, 2000, 4000, 8000]
173
495
  ```
174
496
 
497
+ **The error decides too, not only the attempt count** (`As of 2026-08`). `executeJob` reads the
498
+ thrown error's retry classification — `@ultimat3/core`'s `registerErrorRetry`, the same table
499
+ `--json` and an HTTP client read — and a code nobody classified keeps the attempt-count path
500
+ exactly as it had before.
501
+
502
+ | Thrown | What the queue does |
503
+ |---|---|
504
+ | a `terminal` code (`X_SCRAPE_AUTH_FAILED`, a validation fault, a permission denial) | dead-lettered on the attempt it happened, `attempt` recorded, remaining attempts unspent — a rotated password retried five times is five more wrong passwords at a site that locks the account after three |
505
+ | a `retry-after` code (`X_RATE_LIMITED`, `X_OVERLOADED`) | retried at the time the responder NAMED — `meta.retryAfterSeconds`, clamped by the policy's `maxDelay` — instead of the backoff. Still an attempt, still under the ceiling |
506
+ | a `retryable` code (`X_TIMEOUT`, `X_DRAINING`) | the backoff schedule above, unchanged |
507
+ | an **unclassified** code, or anything that is not an `UltimateError` | the backoff schedule above, unchanged. Most codes are unclassified and `retryFor` answers `terminal` for all of them, so reading that would have stopped every transient retry in every app |
508
+
509
+ Classify your app's codes beside the module that declares them — that import IS the registration:
510
+
511
+ ```
512
+ registerErrorRetry({ X_INVOICE_REJECTED: 'terminal', X_GATEWAY_BUSY: 'retry-after' });
513
+ ```
514
+
515
+ Why a job stopped is on the row and in the log, never inferred: `jobs.attempt.failed` carries
516
+ `stop: 'terminal' | 'attempts-exhausted'`, `JobExecution.stopReason` carries the same, and a
517
+ terminal dead letter appends its verdict to `lastError` so `x jobs show` explains an attempt 1 of 5.
518
+
175
519
  ## Limits
176
520
 
177
- Per-tenant concurrency (`tenantId` = the actor's `orgId`, carried on the queue row), a
178
- per-queue cap and a global cap. Over a cap, the claim is handed straight back without
179
- burning an attempt one org's 50k-row import cannot starve the fleet.
521
+ Two layers, and the difference matters:
522
+
523
+ | Layer | Scope | Where |
524
+ |---|---|---|
525
+ | `LimitConfig` — `perTenant`, `perQueue`, `global`, `ratePerTenant` | **this process only** | `limits.ts`, three `Map`s in one heap |
526
+ | `job.concurrency` | **the fleet** | `JobDriver.leases` over `x_job_leases` |
527
+
528
+ `LimitConfig` is the fast path and is multiplied by your replica count: `perTenant: 2` on twenty
529
+ pods is forty concurrent runs, and `ratePerTenant`'s window is in memory, so a rolling restart
530
+ grants every tenant a fresh full allowance. Size it as a per-pod budget, never as a partner's
531
+ contractual rate.
532
+
533
+ `job.concurrency` is the one that is fleet-wide, and it is enforced by a row every replica can
534
+ see — one per held slot, keyed `job:<name>`, renewed by the same heartbeat that renews the
535
+ visibility lease and reclaimed by TTL when a worker is SIGKILLed. A driver with no lease store
536
+ cannot hold the cap, so `createWorker().start()` **refuses to boot**
537
+ (`X_JOB_CONCURRENCY_UNENFORCEABLE`) rather than let a documented guarantee do nothing.
538
+
539
+ Over any cap the claim is handed straight back without burning an attempt — one org's 50k-row
540
+ import cannot starve the fleet.
541
+
542
+ ## Leases
543
+
544
+ A claim buys `visibilityTimeoutMs` of invisibility; the worker renews it every
545
+ `heartbeatIntervalMs` (default a third of the window) for as long as the job runs. Renewal
546
+ failures are not swallowed:
547
+
548
+ | Fact | Signal |
549
+ |---|---|
550
+ | a renewal failed, the window still has room | `jobs.heartbeat.failed` (warn) |
551
+ | a whole window passed with none landing | `jobs.lease.lost` (error) + `job_leases_lost_total{queue}` |
552
+
553
+ The second one means the queue is free to hand that job to another worker while this one is
554
+ still running it — at-least-once turning into twice. Alert on any non-zero rate. The window is
555
+ measured from the last renewal that **landed**, on this process's clock, so a driver whose
556
+ heartbeat hangs is caught the same as one that rejects.
557
+
558
+ Neither fires for a job that finished (`As of 2026-08`). `stop()` is terminal for the renewal
559
+ already **on the wire**, not only for the next one: a clean completion acks the row out of
560
+ `running`, so the fenced UPDATE already in flight comes back `false` — and reported, that was
561
+ `jobs.lease.lost` at error plus the counter, a page for a non-event, on every completed job whose
562
+ pool was slow enough. The fleet slot's `jobs.worker.slot-lost` had the same shape and the same
563
+ fix (`renewal-timer.ts`).
180
564
 
181
565
  ## Introspection
182
566
 
183
567
  `inspectQueues`, `inspectJob` (per-step trace), `inspectDeadLetters`, `retryFromStep`,
184
- `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
568
+ `cancelJob`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
569
+
570
+ `cancelJob(driver, id, reason?)` is the answer to a runaway pass. A queued row becomes `cancelled`
571
+ immediately; a RUNNING one stops at its next heartbeat, which no longer matches its own row and
572
+ aborts the attempt — `steps.ts` then refuses every write. `ack` and `nack` are fenced on
573
+ `state = 'running'`, so the worker that was cancelled cannot un-cancel it on the way out.
574
+
575
+ ## Metrics
576
+
577
+ | Series | Kind | Answers |
578
+ |---|---|---|
579
+ | `queue_depth{queue}` | gauge | how much work is waiting — the HPA's signal |
580
+ | `jobs_total{queue,outcome}` | counter | is any of it succeeding |
581
+ | `queue_oldest_ready_seconds{queue}` | gauge | *"page if the oldest job in `payments` is older than 5 minutes"* |
582
+ | `queue_dead_jobs{queue}` | gauge | a dead-letter queue that filled overnight and stopped growing — a counter's rate is flat there |
583
+
584
+ ## Trace and actor
585
+
586
+ An enqueue stamps the current span's `traceparent` onto the row, and the worker opens the job's
587
+ span as a **child** of it: a checkout trace shows the HTTP span, the action span and the charge
588
+ that ran two seconds later as one trace.
589
+
590
+ `handle.as(actor, input)` also records `enqueuedBy` — the actor's id. **Attribution, never
591
+ authority.** A job body runs with system authority; the framework does not impersonate the
592
+ enqueuer, because a job that sleeps three days or dead-letters and is retried next quarter would
593
+ act as somebody whose role, org membership or employment has changed since. A job that must act
594
+ FOR a user takes that user's id in its input and re-authorises it in the body.
185
595
 
186
596
  ## Errors
187
597
 
@@ -194,6 +604,11 @@ burning an attempt — one org's 50k-row import cannot starve the fleet.
194
604
  | `X_JOB_MAX_ATTEMPTS` | retries exhausted, job dead-lettered |
195
605
  | `X_OUTBOX_NO_TX` | enqueue outside a transaction with `mode: 'required'` |
196
606
  | `X_DRIVER_UNAVAILABLE` | no `DATABASE_URL` / executor for the pg driver |
607
+ | `X_ABORTED` | a cancelled attempt tried to write a step — core's code, not a second name for it |
608
+ | `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 |
609
+ | `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 |
610
+ | `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that already finished, or a driver with no `cancel` |
611
+ | `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency` and the driver has no lease store |
197
612
  | `X_NOT_IMPLEMENTED` | redis / nats driver |
198
613
 
199
614
  ## Boundary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "1.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,8 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
23
+ "CLAUDE.md",
22
24
  "README.md",
23
25
  "LICENSE"
24
26
  ],
@@ -30,9 +32,9 @@
30
32
  "test": "bun test"
31
33
  },
32
34
  "dependencies": {
33
- "@ultimat3/core": "1.2.0",
34
- "@ultimat3/entity": "1.2.0",
35
- "@ultimat3/schema": "1.2.0",
36
- "@ultimat3/time": "1.2.0"
35
+ "@ultimat3/core": "3.0.0",
36
+ "@ultimat3/entity": "3.0.0",
37
+ "@ultimat3/schema": "3.0.0",
38
+ "@ultimat3/time": "3.0.0"
37
39
  }
38
40
  }
@@ -0,0 +1,97 @@
1
+ // May this named sweep run here, now? One decision, pure, so the pass, the CLI, a test and a
2
+ // deploy container all read the same verdict instead of four almost-identical `if`s. It RETURNS
3
+ // the refusal rather than throwing it: `x db backfill --all` isolates per name and continues past
4
+ // a failure, and a thrown verdict would let one wedged cleanup block every later one forever.
5
+ //
6
+ // The environment half is enforced inside the pass as well, because a backfill enqueued by app
7
+ // code never passes through the CLI — the check has to sit where the work is, or it is a
8
+ // convention rather than a rail (axiom 3).
9
+
10
+ import type { Environment, UltimateError } from '@ultimat3/core';
11
+ // `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
12
+ // own row shape, which would make this a second reader of `x_backfills`.
13
+ import type { BackfillProgress } from './backfill-inspect';
14
+ import type { BackfillDeclaration } from './backfill-registry';
15
+ import {
16
+ BackfillAppliedError,
17
+ BackfillEnvironmentError,
18
+ BackfillMigrationPendingError,
19
+ } from './errors';
20
+
21
+ export type BackfillGate =
22
+ | { readonly run: true }
23
+ | { readonly run: false; readonly error: UltimateError };
24
+
25
+ const ALLOWED: BackfillGate = { run: true };
26
+
27
+ /**
28
+ * An absent `environments` means EVERY environment. Never an implied "production only": a staging
29
+ * rehearsal is correct practice, and a framework that guessed which deploys a cleanup belongs to
30
+ * would be shipping one business's convention to every app (axiom 8).
31
+ */
32
+ export function checkBackfillEnvironment(
33
+ backfill: string,
34
+ declared: readonly Environment[] | null,
35
+ environment: Environment,
36
+ ): BackfillEnvironmentError | undefined {
37
+ if (declared === null || declared.length === 0) return undefined;
38
+ if (declared.includes(environment)) return undefined;
39
+ return new BackfillEnvironmentError({ backfill, environment, declared });
40
+ }
41
+
42
+ export interface BackfillGateInput {
43
+ readonly declaration: BackfillDeclaration;
44
+ readonly environment: Environment;
45
+ /**
46
+ * Migration ids `x_migrations` records as applied. `undefined` means the caller could not read
47
+ * the ledger, and an unreadable ledger is deliberately NOT a refusal — a `requires` that blocked
48
+ * on "I could not check" would make every driver with no database an unrunnable backfill.
49
+ */
50
+ readonly appliedMigrations: readonly string[] | undefined;
51
+ /** The newest COMPLETED pass under this name, when the ledger holds one. */
52
+ readonly completed: BackfillProgress | undefined;
53
+ readonly force: boolean;
54
+ }
55
+
56
+ /**
57
+ * Order is the order an operator can act in: environment first (nothing else matters if this
58
+ * process may not run it at all), then the migration it waits on, then whether it already ran.
59
+ * A live pass is NOT judged here — that verdict belongs to the enqueue, which is the only thing
60
+ * that can see the one live idempotency key without racing it.
61
+ */
62
+ export function gateBackfill(input: BackfillGateInput): BackfillGate {
63
+ const { declaration } = input;
64
+ const environment = checkBackfillEnvironment(
65
+ declaration.name,
66
+ declaration.environments,
67
+ input.environment,
68
+ );
69
+ if (environment !== undefined) return { run: false, error: environment };
70
+
71
+ const requires = declaration.requires;
72
+ if (
73
+ requires !== null &&
74
+ input.appliedMigrations !== undefined &&
75
+ !input.appliedMigrations.includes(requires)
76
+ ) {
77
+ return {
78
+ run: false,
79
+ error: new BackfillMigrationPendingError({ backfill: declaration.name, migration: requires }),
80
+ };
81
+ }
82
+
83
+ const completed = input.completed;
84
+ if (completed !== undefined && !input.force) {
85
+ return {
86
+ run: false,
87
+ error: new BackfillAppliedError({
88
+ backfill: declaration.name,
89
+ runId: completed.runId,
90
+ // Verbatim: the projection already rendered it as ISO, and the repo forbids a date
91
+ // formatted without an explicit zone — not formatting at all is the one render with none.
92
+ completedAt: completed.completedAt ?? completed.startedAt,
93
+ }),
94
+ };
95
+ }
96
+ return ALLOWED;
97
+ }