@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 +146 -15
- package/README.md +80 -4
- package/package.json +5 -5
- package/src/backfill-errors.ts +137 -0
- package/src/backfill-gate.ts +5 -5
- package/src/backfill-pass.ts +1 -1
- package/src/describe.ts +17 -1
- package/src/driver-memory.ts +34 -8
- package/src/driver-pg-ddl.ts +31 -6
- package/src/driver-pg-rows.ts +34 -6
- package/src/driver-pg-sql.ts +79 -9
- package/src/driver-pg.ts +15 -5
- package/src/driver.ts +36 -12
- package/src/errors.ts +81 -130
- package/src/execute.ts +33 -9
- package/src/heartbeat.ts +15 -13
- package/src/index.ts +23 -8
- package/src/job.ts +55 -1
- package/src/metrics.ts +1 -1
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +58 -7
- package/src/outbox.ts +91 -8
- package/src/register.ts +25 -1
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/retry.ts +4 -3
- package/src/steps.ts +14 -1
- package/src/task.ts +29 -2
- package/src/worker-fleet-slots.ts +16 -11
- package/src/worker-run.ts +3 -0
- package/src/worker.ts +34 -9
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
|
|
39
|
-
2026-08`).
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
405
|
-
|
|
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
|
|
442
|
-
|
|
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
|
-
| `
|
|
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 —
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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": "
|
|
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": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
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
|
+
}
|
package/src/backfill-gate.ts
CHANGED
|
@@ -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 }
|
package/src/backfill-pass.ts
CHANGED
|
@@ -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
|
|
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
|
|