@ultimat3/jobs 2.0.0 → 4.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.
package/CLAUDE.md CHANGED
@@ -35,16 +35,35 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
35
35
  A look-alike never registers. Deliberately not a registry lookup — the registry is what
36
36
  registration rewrites.
37
37
  - `idempotencyKey` is NON-OPTIONAL in `JobDefinition`. Never relax it, never default it.
38
- - **The idempotency namespace is `(name, idempotency_key)`, never the key alone** (`As of
39
- 2026-08`). It was the key alone, and that is silent data loss with no error anywhere: two jobs
40
- that derive the same natural key from the same input — `sendWelcomeEmail` and
41
- `provisionWorkspace` both keyed `user:${id}` — shared one namespace, so the second enqueue hit
42
- `on conflict do nothing`, fell through to `SQL_FIND_LIVE_BY_KEY`, found the FIRST job's row and
43
- returned `{ id: <A's>, deduped: true }`. The workspace was never provisioned and `x jobs ls`
44
- showed one healthy job. Three places have to agree and a test pins them together: the index, the
45
- conflict target, and the live-row lookup. `x_jobs` SHIPPED, so the DDL `drop index if exists`es
46
- the old one left in place it would keep enforcing exactly the collision this fixes. The
47
- scheduler's occurrence key already prefixes the task name and is unaffected.
38
+ - **The idempotency namespace is `(name, coalesce(tenant_id, ''), idempotency_key)`, never the key
39
+ alone and never name-only** (`As of 2026-08`). Two rounds of the same defect, and both are silent
40
+ data loss with no error anywhere.
41
+
42
+ It was the key alone: two jobs deriving the same natural key from the same input
43
+ `sendWelcomeEmail` and `provisionWorkspace` both keyed `user:${id}` shared one namespace, so
44
+ the second enqueue hit `on conflict do nothing`, fell through to `SQL_FIND_LIVE_BY_KEY`, found
45
+ the FIRST job's row and returned `{ id: <A's>, deduped: true }`. The workspace was never
46
+ provisioned and `x jobs ls` showed one healthy job.
47
+
48
+ Then it was name-only, while the row already carried `tenant_id` as `$9` of the same insert.
49
+ Every natural key the docs suggest is unique only WITHIN a tenant — `` `invoice:${input.invoiceId}` ``,
50
+ `` `order:${input.orderNumber}` `` — so tenant B enqueuing while tenant A held that key deduped
51
+ into tenant A's row: B's work never ran AND B's caller received A's job id, which is valid on
52
+ every id-addressed surface (`cancelJob(driver, jobId)` takes an id with no tenant predicate, so
53
+ an app wiring the returned id to a cancel button gave B cancellation of A's job). The sibling
54
+ projection in `@ultimat3/action` (`idempotency-key.ts`) had folded the actor's org in all along.
55
+ `coalesce`, not the bare column: a null `tenant_id` compares unequal to every other null under a
56
+ unique index, so a tenantless queue would lose its dedupe entirely — all tenantless rows share
57
+ one namespace instead, which is what they had before tenancy existed.
58
+
59
+ Three places have to agree and a test pins them together: the index, the conflict target, and the
60
+ live-row lookup. The conflict target must spell the index EXPRESSION exactly —
61
+ `(name, (coalesce(tenant_id, '')), idempotency_key)` — or Postgres cannot infer the index at all.
62
+ `driver-memory.ts` mirrors it with `(record.tenantId ?? '')`, which is the parity that turned a
63
+ gap into a confirmed one rather than catching it. `x_jobs` SHIPPED, so the DDL
64
+ `drop index if exists`es BOTH superseded indexes — each is strictly narrower than its successor,
65
+ so either left in place would keep enforcing exactly the collision this fixes. The scheduler's
66
+ occurrence key already prefixes the task name and is unaffected.
48
67
  - **`SQL_JOBS_TABLE` is the ONE install point, and every durable table this package owns is in
49
68
  it** (`As of 2026-08`): `x_jobs`, `x_job_steps`, `x_backfills`, `x_outbox`, `x_scheduler_state`,
50
69
  `x_scheduler_leader`, `x_job_leases`, `x_job_events`. Four of those were subsystems that shipped
@@ -237,6 +256,30 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
237
256
  so, not a job that failed: nacking it re-runs completed work and records `retried` in
238
257
  `jobs_total{outcome}` for a failure that never happened. It propagates instead, and the worker's
239
258
  `jobs.worker.settle-failed` plus the lapsing lease are the honest answer.
259
+ - **The retry decision reads the ERROR as well as the attempt count — added 2026-08.**
260
+ `executeJob` decided a retry from `nextRetry(handle.retry, attempt)` alone, so every `terminal`
261
+ classification in the framework was decorative on the job path: an `X_SCRAPE_AUTH_FAILED` from a
262
+ rotated password burned the whole policy, which at a site that locks an account after three wrong
263
+ passwords makes the framework's retry the thing that destroys the account.
264
+ `retry-classification.ts` composes AROUND `nextRetry` — the backoff arithmetic stays in one
265
+ place — and `nextRetryForError` is the only caller `execute.ts` has.
266
+
267
+ **`classifyThrown` must never read `error.retry` on its own.** That field is
268
+ `init.retry ?? retryFor(code)` and `retryFor` FAILS CLOSED, so every unclassified `UltimateError`
269
+ already carries `terminal`; reading it would dead-letter the first attempt of every job in every
270
+ app whose codes nobody has classified. Hence core's `declaredErrorRetry(code)`, which answers
271
+ `undefined` where `retryFor` answers the default — and hence the one case that is knowingly
272
+ under-read: an instance `retry: 'terminal'` on an UNREGISTERED code is indistinguishable from the
273
+ default and is treated as unclassified. Register the code; that is the one way.
274
+ `retry-after` reuses the delay the nack already takes (`meta.retryAfterSeconds`, clamped by the
275
+ policy's `maxDelay`) rather than a second suspension mechanism — `StepSuspension` stays the only
276
+ way to park a run, and unlike a suspension a retry-after DOES burn an attempt, because the work
277
+ failed. The ceiling outranks every classification but `terminal`.
278
+
279
+ **The verdict is published, not inferred**: `jobs.attempt.failed` and `reportError` carry
280
+ `stop`, `JobExecution` carries `stopReason`, and `recordedFailure` appends the terminal verdict to
281
+ the nack's `error` — `lastError` is the ONE failure field a row has, so without it `x jobs show`
282
+ renders a dead letter at attempt 1 of 5 as a silent early stop.
240
283
  - **The claim loop re-arms on the PASS, never on the jobs.** A slot belongs to its own job and is
241
284
  free the moment it settles, so `claimRound` starts what it claimed and returns the promises —
242
285
  ending the pass on `Promise.allSettled([...inFlight])` made the pool as slow as its slowest
@@ -401,8 +444,80 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
401
444
  A SIGTERM landing between `driver.enqueue` and `markPublished` returned to a caller that then
402
445
  closed the database under the row it was about to mark. `OutboxRelay.stop(): Promise<void>` — a
403
446
  caller that does not await gets what it always got (the chain carries its own `catch`), so the
404
- join is only as good as the `await`: **`packages/cli/src/dev-roles.ts` still calls it without
405
- one**, in both teardown paths.
447
+ join is only as good as the `await`. Both of `packages/cli/src/dev-roles.ts`'s paths take it
448
+ today (`:306` returns `() => relay.stop()` so the rollback awaits it, `:356` is
449
+ `await relay?.stop()`); this file claimed the opposite until 2026-08.
450
+ - **The outbox claim is a LEASE, and one statement is what makes it one** (`As of 2026-08`).
451
+ `SQL_OUTBOX_CLAIM` was a bare `select ... for update skip locked` run on the POOLED executor, and
452
+ those row locks last only for their own statement — under autocommit they are gone before
453
+ `claim()` resolves, and `x_outbox` had no claimed column, so nothing fenced the batch at all. Two
454
+ relays 200ms apart read the identical rows and both published them. **The idempotency key does
455
+ not collapse that in general**: `SQL_ENQUEUE`'s conflict target is the PARTIAL index over
456
+ `('ready','delayed','running','suspended')`, so a repeat landing after the first job reached a
457
+ terminal state inserts a second row and the handler runs twice. The mechanism is Postgres
458
+ semantics; the second-publish-after-terminal ordering was argued, never reproduced — say it that
459
+ way, in a comment or a changelog. The claim now stamps `claimed_at`/`claimed_by` in the same
460
+ statement that locks (the CTE shape `SQL_CLAIM` already used), and the outer
461
+ `select ... order by staged_at, id` is load-bearing: `update ... returning` has no defined row
462
+ order and the relay publishes in the order it is handed rows. `claimed_at` is a LEASE and not a
463
+ flag — without a reclaim window a relay that died mid-batch strands its rows forever — and
464
+ `OutboxStore.release` (optional, so a store written before this still compiles) hands back the
465
+ batch a failed publish stopped, or one pool blip would park committed work for a whole lease
466
+ window instead of one poll interval. `createMemoryOutboxStore` answers the SAME question, on an
467
+ injected clock, and `outbox-claim.test.ts` pins the two side by side.
468
+ - **The lease is fenced on EVERY outbox mutation, and the sort key is TOTAL** (`As of 2026-08`).
469
+ Two holes the first version of the lease left open, both reachable without a second relay
470
+ process. `SQL_OUTBOX_RELEASE` and `SQL_OUTBOX_MARK_PUBLISHED` matched on `id` alone, so a relay
471
+ that stalled past its own lease still spoke for rows another relay had reclaimed: its late
472
+ `release` unclaimed a batch mid-publish (a third relay claims it, publishes it again — the
473
+ duplicate the lease exists to prevent, reached the long way round) and its late `markPublished`
474
+ retired a row nobody had published, losing the job with nothing to notice. Both now carry
475
+ `and claimed_by = $n`, `claim()` hands the token back as `OutboxRecord.claimedBy`, and the relay
476
+ passes it to both calls. `markPublished` also gained `published_at is null`, so the stamp is
477
+ first-writer-wins rather than a rewrite of an audit timestamp. The memory store fences the same
478
+ way — per CLAIM there rather than per relay, because two relays there are two `claim()` calls on
479
+ ONE store, and a per-store id could not tell them apart. **An absent token is NOT one rule in
480
+ both**: `createMemoryOutboxStore`'s `owns(id, undefined)` answers `true` unconditionally, so a
481
+ caller with no token really is unfenced there — while `createPgOutboxStore` substitutes
482
+ `claimant ?? relayId` into `SQL_OUTBOX_RELEASE` and `SQL_OUTBOX_MARK_PUBLISHED`, both of which
483
+ carry `and claimed_by = $n`, so a token-less call fences on THIS store's relay id and no-ops
484
+ against a row some other relay holds. `outbox-pg.ts:159-165` is the honest comment. Neither store
485
+ refuses such a caller, which is the shared half: a caller holding no token is one written before
486
+ the fence.
487
+ **`order by staged_at` was not a total order**: every row staged in one transaction shares a
488
+ `staged_at`, so the tie was the planner's to break — which rows the `limit` takes, and in which
489
+ order they publish, differed between two relays and between two runs of one. `, id` fixes it in
490
+ the CTE and in the projection, and needed NO DDL: `id` is a UUIDv7 minted by `uuid()`, monotonic
491
+ and already the primary key, so the tiebreak IS stage order. `byClaimOrder` in `outbox.ts` is the
492
+ memory store's copy of that key.
493
+ - **`claimLeaseMs` is normalised in ONE place — `outbox-lease.ts`** (`As of 2026-08`). Both stores
494
+ call `resolveClaimLeaseMs`, which owns `DEFAULT_OUTBOX_CLAIM_LEASE_MS` and refuses anything that
495
+ is not a positive whole number of ms with `X_INVARIANT` (the generic, no new code: same borrow
496
+ `@ultimat3/db` makes). A memory default and a pg default that could drift are two answers to
497
+ "how long is a claim mine for", and the shorter one publishes a row twice. `0` expires before
498
+ `claim()` resolves and `Infinity` never expires, so both are refused at CONSTRUCTION, not at the
499
+ first tick where the only trace is a log line.
500
+ - **A renewal is decided against `stopped()`, not only against the interval** (`As of 2026-08`).
501
+ `renewal-timer.ts` is the one shape, read by `heartbeat.ts` and `worker-fleet-slots.ts`, and it
502
+ exists because both files reported a LOSS for a job that had finished cleanly: `stop()` cleared
503
+ the interval, which does nothing to the request already on the wire, so the fenced statement came
504
+ back `false` — the row left `running` when `executeJob` acked it — and that answer took the loss
505
+ branch. `jobs.lease.lost` at error plus `recordLeaseLost(queue)` is the one signal meaning the
506
+ queue re-delivered a job this process was still running, so a false one is a page for a
507
+ non-event, and the window widens exactly when the pool is slow. Re-read the flag AFTER every
508
+ await and inside the reporter, the way `settleWithin`'s `decided` does in core.
509
+ - **`stepTimeout` and `eventPoll` are DECLARED on the job, and `execute.ts` is the only place they
510
+ are forwarded** (`As of 2026-08`). `StepRunnerOptions` carried both, `withStepTimeout`
511
+ implemented the ceiling and `steps.test.ts` exercised it by building a runner BY HAND — while the
512
+ only production construction passed neither and `JobDefinition` had no field that could. Same
513
+ verdict as `job.concurrency`: a documented guarantee that silently does nothing is the worst of
514
+ the three options, so it is threaded rather than deleted. Both are refused at declaration when
515
+ non-positive, because `withStepTimeout` reads `<= 0` as "no ceiling at all".
516
+ - **`registeredJobs()`/`registeredTasks()` sort by CODE UNITS, never `localeCompare`.** The list is
517
+ projected by `describeJobs()` into `x.manifest.json`, which both tracked apps commit and
518
+ `x verify`'s `drift` step diffs byte for byte; `localeCompare` with no locale argument answers
519
+ from the runtime's ICU default and collation version. Same rule as `@ultimat3/http`'s
520
+ `describeRoutes`, restated locally rather than imported — `http` is not below this package.
406
521
  - **A driver's semantics are pinned in ONE test with the pg statement beside them.**
407
522
  `driver-parity.test.ts` asserts the memory driver's behaviour and the SQL that has to mean the
408
523
  same thing in a single test, so neither side can move alone. `introspect.list` answered
@@ -438,8 +553,20 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
438
553
  `backfill-pass-fixture.ts` raises `BackfillHandleFailure`, a plain `Error` subclass on purpose:
439
554
  a backfill `handle` is app code and the pass propagates what it threw, so a framework code there
440
555
  would exercise a path no app takes.
441
- - Suspension is control flow: `StepSuspension` -> `nack({ countsAsAttempt: false })`.
442
- Never log it as an error, never let it burn an attempt.
556
+ - **Suspension is control flow, and a SHED is not a suspension** (`As of 2026-08`).
557
+ `StepSuspension` -> `nack({ countsAsAttempt: false, park: true })`; never log it as an error,
558
+ never let it burn an attempt. The two facts were ONE flag until 2026-08: a limiter shed and a
559
+ `job.concurrency` shed both handed the job back with `countsAsAttempt: false`, and both drivers
560
+ derived `deadLetter ? 'dead' : counts ? 'ready' : 'suspended'` — so a job that is merely WAITING
561
+ was filed beside a 3-day sleep. `SQL_STATS` and the memory `stats()` then counted it out of
562
+ `ready` and out of `oldest_ready_ms`, which `worker.ts` publishes as `queue_depth` and
563
+ `queue_oldest_ready_seconds`: 20 jobs at `concurrency: 10` behind `createLimiter({ global: 1 })`
564
+ read as a depth of 10 with 19 waiting, and under sustained overload the shed fraction approaches
565
+ 100%, so the HPA signal and the "oldest job older than 5 minutes" page both go quiet exactly
566
+ when the queue is saturated. `park` is now the state and `countsAsAttempt` is the counter, only.
567
+ The shed also wrote `last_error = 'limited: …'`, so `x jobs show` reported a failure for a job
568
+ that never ran — it is a `jobs.worker.shed` log field now, and `worker.ts`'s one `shed()` is
569
+ where both sheds go. `driver-parity.test.ts` pins which bucket each lands in, in both drivers.
443
570
  - Step results are persisted BEFORE the step returns. Keep it that way or replay breaks.
444
571
  - All time is epoch ms from an injected `Clock`, read via `nowMs()` in `clock.ts`.
445
572
  - Drivers implement exactly the six `JobDriver` methods plus optional `introspect`, `backfills`
@@ -514,11 +641,13 @@ picture from the other side.
514
641
  | `backfill-pending.ts` | declared minus completed, per environment: the alarm `--pending` reads |
515
642
  | `backfill-rate.ts` | the `rate` throttle: batches/sec as an interval, and the cancellable wait |
516
643
  | `backfill-inspect.ts` | the ledger projected for `x db backfill`, `x jobs`, `/_x` and MCP |
517
- | `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements |
644
+ | `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 |
645
+ | `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` |
518
646
  | `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
519
647
  | `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
520
648
  | `outbox.ts` | staging in a `Tx`, the relay, the ambient `JobsFacade` slot |
521
649
  | `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
650
+ | `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
522
651
  | `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
523
652
  | `metrics.ts` | `queue_oldest_ready_seconds` and `queue_dead_jobs`, the two alertable gauges |
524
653
  | `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark) + `createPgLeaseLeader` |
@@ -530,8 +659,10 @@ picture from the other side.
530
659
  | `driver-memory.ts` | `x dev` / tests |
531
660
  | `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
532
661
  | `retry.ts` | backoff arithmetic, dead-letter decision |
662
+ | `retry-classification.ts` | the OTHER half of that decision: what the thrown error says, and the stop reason the row and the log carry |
533
663
  | `execute.ts` | `executeJob` — one claimed job run and settled, and the run's deadline/cancel |
534
664
  | `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
665
+ | `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
535
666
  | `worker.ts` | `worker` role, claim loop, drain |
536
667
  | `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` |
537
668
  | `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
package/README.md CHANGED
@@ -315,6 +315,20 @@ Nothing can kill a body that ignores the signal, so the durable state is fenced:
315
315
  cancel every step write is refused with `X_ABORTED`, and a run that finishes anyway is logged
316
316
  as `jobs.timeout.abandoned` — the one way to find a handler that never reads `ctx.signal`.
317
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
+
318
332
  ## The transactional outbox
319
333
 
320
334
  ```ts
@@ -334,10 +348,15 @@ it after commit. The bug class this removes:
334
348
 
335
349
  Both are load-dependent, both pass every test you would write, and both produce "the email
336
350
  went out but the order isn't in the database". Joining the transaction closes the window.
337
- The relay publishes *then* marks published, so a crash re-publishes — collapsed by the
338
- idempotency key. A publish that FAILS stops the batch rather than letting later rows overtake it:
339
- `claim()` returns rows in `staged_at` order, so an app that stages `createInvoice` then
340
- `chargeCard` in one transaction must never have the charge run first. Set `mode: 'required'` to
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
341
360
  make an enqueue outside a transaction an `X_OUTBOX_NO_TX` error instead of a direct publish.
342
361
 
343
362
  **It is not on by default, and it is not on until you install it** (`As of 2026-08`). Three
@@ -355,6 +374,34 @@ transactional: `stage()` runs on the CALLER'S connection, never the pool. With n
355
374
  publishes straight to the driver — deliberate, so a script and a test enqueue with no wiring, but
356
375
  it is a fallback and not the guarantee.
357
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
+
358
405
  The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
359
406
  `retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
360
407
  the audit trail this map is not. A relay pass that throws is logged as `jobs.outbox.tick-failed`
@@ -447,6 +494,28 @@ retrySchedule({ attempts: 5, backoff: 'exponential', delay: 1000 })
447
494
  // => [1000, 2000, 4000, 8000]
448
495
  ```
449
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
+
450
519
  ## Limits
451
520
 
452
521
  Two layers, and the difference matters:
@@ -486,6 +555,13 @@ still running it — at-least-once turning into twice. Alert on any non-zero rat
486
555
  measured from the last renewal that **landed**, on this process's clock, so a driver whose
487
556
  heartbeat hangs is caught the same as one that rejects.
488
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`).
564
+
489
565
  ## Introspection
490
566
 
491
567
  `inspectQueues`, `inspectJob` (per-step trace), `inspectDeadLetters`, `retryFromStep`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "2.0.0",
3
+ "version": "4.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,9 +32,9 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "2.0.0",
36
- "@ultimat3/entity": "2.0.0",
37
- "@ultimat3/schema": "2.0.0",
38
- "@ultimat3/time": "2.0.0"
35
+ "@ultimat3/core": "4.0.0",
36
+ "@ultimat3/entity": "4.0.0",
37
+ "@ultimat3/schema": "4.0.0",
38
+ "@ultimat3/time": "4.0.0"
39
39
  }
40
40
  }
@@ -0,0 +1,137 @@
1
+ // The seven `X_BACKFILL_*` codes, apart from `errors.ts` for the reason `driver-pg-rows.ts` is
2
+ // apart from `driver-pg.ts`: one file, one job, and `errors.ts` was over the 500-line ceiling
3
+ // `x verify`'s `filesize` step enforces. The registry stays there — `JOB_OWNED_ERROR_CODES`,
4
+ // `JOB_ERROR_TITLES` and the single `registerErrorCodes()` call — because a package's codes are
5
+ // declared in ONE place; only the classes that throw them live here, beside `backfill-ledger.ts`,
6
+ // `backfill-pending.ts` and `backfill-registry.ts`.
7
+
8
+ import { UltimateError } from '@ultimat3/core';
9
+ import { docsFor } from './errors';
10
+
11
+ /**
12
+ * The seven backfill codes below all answer one question — "why is this sweep not running?" — and
13
+ * each is here because it sends the reader somewhere different: run it, force it, change
14
+ * environment, migrate first, wait, fix the predicates, or fix the name. A code that shared a fix
15
+ * line with another one would be code inflation, which is why `X_BACKFILL_WRITE_UNCONFIRMED` was
16
+ * considered and rejected: a dry run that wrote nothing did exactly what it was asked to.
17
+ *
18
+ * Every `fix` below is ONE line something can execute — a shell command, or the edit to make.
19
+ * Never a command with prose appended: a `fix:` is copied and run verbatim, so a trailing clause
20
+ * turns a working command into a syntax error at the one moment the reader is following it
21
+ * literally. Explanations belong in `cause`, which is read and never run.
22
+ */
23
+
24
+ /**
25
+ * Declared and never completed. The alarm the framework did not have: an author could
26
+ * `x g backfill`, merge and deploy, and nothing anywhere said the pass had not run.
27
+ */
28
+ export class BackfillPendingError extends UltimateError {
29
+ constructor(input: { backfill: string; environment: string }) {
30
+ super({
31
+ code: 'X_BACKFILL_PENDING',
32
+ cause: `backfill "${input.backfill}" is declared and x_backfills holds no completed pass for it in ${input.environment}`,
33
+ fix: `x db backfill ${input.backfill} --write --json`,
34
+ docs: docsFor('X_BACKFILL_PENDING'),
35
+ });
36
+ }
37
+ }
38
+
39
+ /** Already swept. A rerun is legitimate, so this names the flag rather than refusing outright. */
40
+ export class BackfillAppliedError extends UltimateError {
41
+ constructor(input: { backfill: string; runId: string; completedAt: string }) {
42
+ super({
43
+ code: 'X_BACKFILL_APPLIED',
44
+ cause: `backfill "${input.backfill}" completed as run ${input.runId} at ${input.completedAt}; a forced rerun writes a NEW ledger row and never edits that one`,
45
+ fix: `x db backfill ${input.backfill} --write --force --json`,
46
+ docs: docsFor('X_BACKFILL_APPLIED'),
47
+ });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The declaration names the environments it belongs to and this is not one. Declared DATA, never a
53
+ * hardcoded "cleanups are production": a staging rehearsal is correct practice, so which
54
+ * environments a sweep belongs to is the app's convention and this is only the mechanism carrying
55
+ * it (axiom 8).
56
+ */
57
+ export class BackfillEnvironmentError extends UltimateError {
58
+ constructor(input: { backfill: string; environment: string; declared: readonly string[] }) {
59
+ // The first declared environment, because the fix has to be ONE runnable line and the list is
60
+ // ordered by the author. The empty case cannot arise from `checkBackfillEnvironment`, which
61
+ // treats an empty list as "every environment" — but this constructor is public, so it answers
62
+ // with the command that lists what IS declared rather than an `ULTIMATE_ENV=undefined`.
63
+ const target = input.declared[0];
64
+ super({
65
+ code: 'X_BACKFILL_ENVIRONMENT',
66
+ cause: `backfill "${input.backfill}" declares environments: ${input.declared.join(', ')} and this process resolved ${input.environment} — add "${input.environment}" to that list if this deploy should sweep too`,
67
+ fix:
68
+ target === undefined
69
+ ? 'x db backfill --pending --json'
70
+ : `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
71
+ docs: docsFor('X_BACKFILL_ENVIRONMENT'),
72
+ });
73
+ }
74
+ }
75
+
76
+ /**
77
+ * `requires` names a migration the ledger has not applied. Checked where `x_migrations` is
78
+ * readable — this package holds no `@ultimat3/db` dependency and growing one to read a ledger
79
+ * would put the migration engine on the tier-3 queue's import graph.
80
+ */
81
+ export class BackfillMigrationPendingError extends UltimateError {
82
+ constructor(input: { backfill: string; migration: string }) {
83
+ super({
84
+ code: 'X_BACKFILL_MIGRATION_PENDING',
85
+ cause: `backfill "${input.backfill}" requires migration ${input.migration}, which x_migrations does not record as applied`,
86
+ fix: 'x db migrate --json',
87
+ docs: docsFor('X_BACKFILL_MIGRATION_PENDING'),
88
+ });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The enqueue deduped: one live pass per name, so this run is the one already going. Distinct from
94
+ * `X_BACKFILL_APPLIED`, which is a pass that finished — the response there is `--force`, and the
95
+ * response here is to look at the run that is holding the key.
96
+ */
97
+ export class BackfillRunningError extends UltimateError {
98
+ constructor(input: { backfill: string; jobId: string }) {
99
+ super({
100
+ code: 'X_BACKFILL_RUNNING',
101
+ cause: `backfill "${input.backfill}" already has a live pass queued as ${input.jobId}, and one name holds one live pass; its step trace names the batch it is on, and a pass that is not advancing is a worker that lost its lease`,
102
+ fix: `x jobs show ${input.jobId} --json`,
103
+ docs: docsFor('X_BACKFILL_RUNNING'),
104
+ });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The source ran out of rows and the declaration's own `count()` still matches some. Two
110
+ * predicates that disagree is an authoring bug in any business — the sweep reported success over
111
+ * rows it never visited — so the pass fails rather than writing a completed row nobody can trust.
112
+ */
113
+ export class BackfillStalledError extends UltimateError {
114
+ constructor(input: { backfill: string; remaining: number; swept: number }) {
115
+ super({
116
+ code: 'X_BACKFILL_STALLED',
117
+ cause: `backfill "${input.backfill}" swept ${input.swept} rows, exhausted its source, and count() still matches ${input.remaining} — a WHERE the sweep narrows and the count does not is what leaves rows behind`,
118
+ fix: `make count() select on exactly what source() selects on in backfill("${input.backfill}")`,
119
+ docs: docsFor('X_BACKFILL_STALLED'),
120
+ });
121
+ }
122
+ }
123
+
124
+ /** A name no declaration in this app carries — a typo, or a backfill whose module was deleted. */
125
+ export class BackfillUnknownError extends UltimateError {
126
+ constructor(input: { backfill: string; known: readonly string[] }) {
127
+ super({
128
+ code: 'X_BACKFILL_UNKNOWN',
129
+ cause:
130
+ input.known.length === 0
131
+ ? `no backfill named "${input.backfill}" is declared, and this app declares none at all`
132
+ : `no backfill named "${input.backfill}" is declared (declared: ${input.known.join(', ')})`,
133
+ fix: 'x db backfill --pending --json',
134
+ docs: docsFor('X_BACKFILL_UNKNOWN'),
135
+ });
136
+ }
137
+ }
@@ -8,15 +8,15 @@
8
8
  // convention rather than a rail (axiom 3).
9
9
 
10
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
11
  import {
16
12
  BackfillAppliedError,
17
13
  BackfillEnvironmentError,
18
14
  BackfillMigrationPendingError,
19
- } from './errors';
15
+ } from './backfill-errors';
16
+ // `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
17
+ // own row shape, which would make this a second reader of `x_backfills`.
18
+ import type { BackfillProgress } from './backfill-inspect';
19
+ import type { BackfillDeclaration } from './backfill-registry';
20
20
 
21
21
  export type BackfillGate =
22
22
  | { readonly run: true }
@@ -16,13 +16,13 @@
16
16
  import { appVersion, assert, logger, resolveEnvironment } from '@ultimat3/core';
17
17
  import type { BatchIterator } from '@ultimat3/entity';
18
18
  import type { BackfillDefinition, BackfillInput, BackfillReport } from './backfill';
19
+ import { BackfillStalledError } from './backfill-errors';
19
20
  import { checkBackfillEnvironment } from './backfill-gate';
20
21
  import type { BackfillLedger, BackfillRun } from './backfill-ledger';
21
22
  import { decideBackfill } from './backfill-ledger';
22
23
  import type { Pacer } from './backfill-rate';
23
24
  import { withBackfillScope } from './backfill-scope';
24
25
  import { jobDriver } from './driver';
25
- import { BackfillStalledError } from './errors';
26
26
  import type { JobRunArgs } from './job';
27
27
  import { isStepSuspension } from './steps';
28
28
 
package/src/describe.ts CHANGED
@@ -14,10 +14,18 @@ export interface JobDescriptor {
14
14
  readonly queue: string;
15
15
  readonly retry: { readonly attempts: number; readonly backoff: BackoffStrategy };
16
16
  readonly steps: readonly string[];
17
+ /**
18
+ * Whether a replayed attempt is safe to run — `job()` REQUIRES an `idempotencyKey` and refuses
19
+ * a definition without one (`X_IDEMPOTENCY_REQUIRED`), so this is `true` for every registered
20
+ * job. That is the point: the guarantee, published where an operator asks the question, rather
21
+ * than left as prose in a doc. The KEY itself never crosses — it is computed from an input and
22
+ * is app data, so a descriptor carrying it would put customer ids in `x.manifest.json`.
23
+ */
24
+ readonly idempotent: boolean;
17
25
  }
18
26
 
19
27
  /**
20
- * Narrower than `JobHandle` on purpose: the projection reads four declared fields, so keeping
28
+ * Narrower than `JobHandle` on purpose: the projection reads five declared fields, so keeping
21
29
  * it structural means it never has to carry — or vary with — the handle's input generic.
22
30
  */
23
31
  export interface DescribableJob {
@@ -25,6 +33,13 @@ export interface DescribableJob {
25
33
  readonly queue: string;
26
34
  readonly retry: RetryPolicy;
27
35
  readonly input: unknown;
36
+ /**
37
+ * `JobHandle.idempotencyKeyFor`, read only for its presence — `unknown` because the real
38
+ * signature is `(input: I) => string` and this shape is deliberately free of the generic.
39
+ * Required, not optional: a descriptor built without it would publish `idempotent: false`,
40
+ * which is the exact wrong answer the `/_x` jobs panel used to give for every job.
41
+ */
42
+ readonly idempotencyKeyFor: unknown;
28
43
  }
29
44
 
30
45
  export function describeJob(handle: DescribableJob): JobDescriptor {
@@ -39,6 +54,7 @@ export function describeJob(handle: DescribableJob): JobDescriptor {
39
54
  // Empty by design: step names are chosen inside `run()` at execution time, so they are
40
55
  // not statically knowable. `inspect(name)` reports the steps an actual run recorded.
41
56
  steps: [],
57
+ idempotent: typeof handle.idempotencyKeyFor === 'function',
42
58
  };
43
59
  }
44
60