@ultimat3/jobs 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CLAUDE.md +165 -124
  2. package/README.md +360 -21
  3. package/package.json +6 -6
  4. package/src/backfill-ledger.ts +24 -2
  5. package/src/backfill-pass.ts +22 -5
  6. package/src/claim-exhausted.ts +76 -0
  7. package/src/concurrency.ts +144 -0
  8. package/src/counters.ts +105 -0
  9. package/src/describe.ts +29 -0
  10. package/src/driver-memory-operator.ts +319 -0
  11. package/src/driver-memory.ts +146 -59
  12. package/src/driver-nats.ts +7 -4
  13. package/src/driver-pg-ddl.ts +71 -0
  14. package/src/driver-pg-jobs-sql.ts +96 -25
  15. package/src/driver-pg-operator-sql.ts +323 -0
  16. package/src/driver-pg-operator.ts +316 -0
  17. package/src/driver-pg-outbox-sql.ts +110 -0
  18. package/src/driver-pg-rows.ts +34 -3
  19. package/src/driver-pg-settle-sql.ts +71 -0
  20. package/src/driver-pg-sql.ts +171 -151
  21. package/src/driver-pg-wake-sql.ts +76 -0
  22. package/src/driver-pg.ts +150 -105
  23. package/src/driver-redis.ts +7 -4
  24. package/src/driver.ts +133 -35
  25. package/src/enqueue-signal.ts +50 -0
  26. package/src/errors-concurrency.ts +91 -0
  27. package/src/errors-operator.ts +102 -0
  28. package/src/errors-requeue.ts +21 -3
  29. package/src/errors.ts +18 -30
  30. package/src/events-pg.ts +45 -45
  31. package/src/events.ts +38 -2
  32. package/src/execute.ts +161 -81
  33. package/src/export.ts +23 -4
  34. package/src/heartbeat.ts +9 -3
  35. package/src/idle-backoff.ts +74 -0
  36. package/src/index.ts +115 -16
  37. package/src/inspect-operator.ts +49 -0
  38. package/src/inspect.ts +46 -5
  39. package/src/introspection.ts +274 -0
  40. package/src/job.ts +83 -21
  41. package/src/leases.ts +49 -9
  42. package/src/limits.ts +33 -7
  43. package/src/metrics.ts +6 -1
  44. package/src/outbox-pg.ts +4 -2
  45. package/src/outbox-relay.ts +114 -39
  46. package/src/outbox.ts +59 -8
  47. package/src/progress.ts +78 -0
  48. package/src/purge.ts +1 -1
  49. package/src/queue-wake.ts +219 -0
  50. package/src/redact-input.ts +26 -0
  51. package/src/renewal-timer.ts +28 -10
  52. package/src/retry-classification.ts +13 -1
  53. package/src/retry.ts +10 -2
  54. package/src/run-deadline.ts +57 -0
  55. package/src/scheduler-leader.ts +26 -0
  56. package/src/scheduler-occurrences.ts +76 -0
  57. package/src/scheduler-pg.ts +58 -4
  58. package/src/scheduler-state.ts +73 -0
  59. package/src/scheduler.ts +150 -111
  60. package/src/settled.ts +161 -0
  61. package/src/steps-suspension.ts +46 -0
  62. package/src/steps-timeout.ts +33 -0
  63. package/src/steps.ts +55 -81
  64. package/src/worker-admit.ts +167 -0
  65. package/src/worker-fleet-slots.ts +157 -54
  66. package/src/worker-hand-back.ts +6 -1
  67. package/src/worker-key-busy.ts +107 -0
  68. package/src/worker-loop.ts +169 -0
  69. package/src/worker-registry.ts +118 -0
  70. package/src/worker-run.ts +9 -0
  71. package/src/worker-types.ts +23 -0
  72. package/src/worker.ts +142 -130
package/README.md CHANGED
@@ -35,7 +35,7 @@ nightlyDigest.describe(); // { kind: 'task', cron, tz, j
35
35
 
36
36
  | Member | On | Behaviour |
37
37
  |---|---|---|
38
- | `enqueue(input, options?)` | `JobHandle` | queues through `jobsFacade()`; joins the caller's `tx` when the outbox is installed |
38
+ | `enqueue(input, options?)` | `JobHandle` | queues through `jobsFacade()`; joins the caller's `tx` when the outbox is installed. Answers `{ id, runId, deduped }` — the job's own ids on every path, staged or direct. `options.runId` names the run instead of having one minted |
39
39
  | `as(actor, input, options?)` | `JobHandle` | enqueue on behalf of an actor — fills `tenantId` from `actor.orgId` so per-tenant limits apply |
40
40
  | `describe()` | `JobHandle` | the `JobDescriptor` the manifest, `/_x` and MCP read |
41
41
  | `entries()` | `TaskHandle` | the declared `[job, input]` pairs |
@@ -188,6 +188,22 @@ input. The tenant is a fact about the work, so the job declares it.
188
188
  | `step.sleep('3d')` | same, step name derived from the duration |
189
189
  | `step.waitForEvent(name, event, { match, timeout })` | suspends until `publishEvent()` matches |
190
190
 
191
+ `resetEventBus()` is the test seam beside `resetJobDriver()`: an event is matchable until it
192
+ expires, so on a bus shared by one `bun test` process an answer one test published resumes the
193
+ next test's wait — call it between tests.
194
+
195
+ The event bus has ONE clock. `createPgEventBus({ executor })` stamps `publishedAt` and the expiry
196
+ with the database's `now()` — it takes no `clock` — and `bus.now()` answers that same clock. A
197
+ consumer that wants "published after I asked" (`@ultimat3/scraping`'s `eventPrompt`) reads its
198
+ "asked at" from `bus.now()`, never from its own process: a skew between two pods must not decide
199
+ which answers count. `step.waitForEvent` stamps a new wait from the same clock
200
+ (`EventLookup.now()`), so a worker running ahead of the database still matches an event published
201
+ after it asked.
202
+ `step.waitForEvent` stamps a new wait from that clock (`EventLookup.now()`, required) and asks it
203
+ again before declaring a timeout, so a worker running ahead of the database still matches an event
204
+ published after it asked and does not give up early. `eventsPurgeTarget(bus)` is the bus as a
205
+ `purge()` target: `purgeExpired()` is awaited and answers the rows it removed.
206
+
191
207
  Step names are the replay key, so they must be deterministic and unique in a run — a
192
208
  duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
193
209
  (`StepSuspension`), never a failure: it does not burn a retry attempt.
@@ -395,6 +411,11 @@ export const hourly = task({
395
411
  | at least once is safe here | a replayed delete removes rows that are already gone, and a row a purge deleted answers exactly as one that was never there |
396
412
  | two targets under one name | `X_INVARIANT`, before the first delete — `step.run` would raise `X_STEP_DUPLICATE` after one table was already empty |
397
413
 
414
+ The stored event bus is a target too: `eventsPurgeTarget(bus)` answers
415
+ `{ name: EVENTS_PURGE_TARGET, purgeExpired }` over `bus.purgeExpired()`, which is awaited and
416
+ answers the rows it removed — `x_job_events` only grew while nothing called it. Add it to
417
+ `targets`: `targets: () => [eventsPurgeTarget(eventBus())]`.
418
+
398
419
  `@ultimat3/cli`'s boot declares both halves over the three tables it owns, so an app gets the sweep
399
420
  without writing any of the above. It needs a `worker` to run it and a `scheduler` to fire it: a
400
421
  deployment with neither has no background work at all, and this is one more thing it does not do.
@@ -440,7 +461,7 @@ export const exportOrders = exportRows({
440
461
  placedAt: formatDate(instant(order.placedAt), { locale: 'en', zone: 'UTC' }),
441
462
  total: formatMoney(order.total, 'en'),
442
463
  }),
443
- sink: disk('exports'), // a StorageDriver already IS an ExportSink
464
+ sink: () => disk('exports'), // a thunk: read per write, after defineStorage() — never at import
444
465
  });
445
466
  ```
446
467
 
@@ -566,7 +587,9 @@ there, as before. A manual `stop()` aborts nothing: it waits for the work it hol
566
587
  | `step.sleep` / `step.waitForEvent` | `suspended` | no | not counted |
567
588
  | the body throwing, attempts left | `retried` | yes | `failed` |
568
589
  | the body throwing, none left or `terminal` | `dead-lettered` | yes | `dead` |
590
+ | the same, on a job declaring `retry.deadLetter: false` | `dropped` — the row is `failed` | yes | `failed` |
569
591
  | the worker's drain (`X_DRAINING` on `ctx.signal`) | `interrupted` | **no** | not counted |
592
+ | `whenBusy: 'fail'` over a busy concurrency key — the body never started | `refused` | **no** | `failed` |
570
593
 
571
594
  Three ceilings, declared on the job and nowhere else (`As of 2026-08` — `stepTimeout` and
572
595
  `eventPoll` had been implemented in the step runner since 1.0 with no declaration able to reach
@@ -601,17 +624,46 @@ it after commit. The bug class this removes:
601
624
 
602
625
  Both are load-dependent, both pass every test you would write, and both produce "the email
603
626
  went out but the order isn't in the database". Joining the transaction closes the window.
604
- The relay publishes *then* marks published, so a crash re-publishes — and **that repeat is
605
- collapsed only while the first job is still live** (`As of 2026-08`): `SQL_ENQUEUE`'s conflict
606
- target is a partial index over `ready`/`delayed`/`running`/`suspended`, so a re-publish landing
607
- after the first job reached a terminal state inserts a second row and the handler runs again.
608
- **Handlers are at-least-once. Write them idempotent** — that is the standing contract, not a
609
- caveat on this paragraph. A publish that FAILS stops the batch rather than letting later rows
627
+ The relay publishes *then* marks published, so a crash re-publishes — and **that repeat meets
628
+ the job the first publish made** (`As of 2026-10`): a staged row is published under its own id
629
+ (`EnqueueRequest.id`), and `SQL_ENQUEUE` inserts nothing under an id that already names a row,
630
+ live or finished. Until then the repeat was collapsed only while the first job was still live —
631
+ the conflict target is a partial index over `ready`/`delayed`/`running`/`suspended` — so one
632
+ landing after it finished ran the handler again. A job row an operator removed is the one case
633
+ left. **Handlers are still at-least-once** — a lease that lapses re-delivers — **so write them
634
+ idempotent**: that is the standing contract, not a caveat on this paragraph. A publish that FAILS stops the batch rather than letting later rows
610
635
  overtake it: `claim()` returns rows in `staged_at, id` order — total, so two relays compose the
611
636
  same batch in the same order — and an app that stages `createInvoice` then `chargeCard` in one
612
637
  transaction must never have the charge run first. Set `mode: 'required'` to
613
638
  make an enqueue outside a transaction an `X_OUTBOX_NO_TX` error instead of a direct publish.
614
639
 
640
+ **The ids are known at enqueue time, on every path** (`As of 2026-10`). A staged enqueue used to
641
+ answer `runId: ''` and the outbox row's id — neither names a job — so an action that had to say
642
+ which run it started had to pass `outbox: false` and give the transaction up. Both ids are
643
+ allocated when the row is staged (`x_outbox.run_id`, and the row id IS the job id), so:
644
+
645
+ ```ts
646
+ import { job, t } from '@ultimat3/jobs';
647
+
648
+ export const syncAccount = job({
649
+ input: t.object({ accountId: t.uuid, orgId: t.uuid, requestId: t.uuid }),
650
+ tenant: ({ orgId }) => orgId,
651
+ idempotencyKey: ({ requestId }) => `sync:${requestId}`,
652
+ retry: { attempts: 1 },
653
+ async run() {},
654
+ });
655
+
656
+ export async function start(accountId: string, orgId: string) {
657
+ const queued = await syncAccount.enqueue({ accountId, orgId, requestId: crypto.randomUUID() });
658
+ // The run its events are keyed by and the job a cancel names — inside the caller's transaction.
659
+ return { runId: queued.runId, jobId: queued.id };
660
+ }
661
+ ```
662
+
663
+ `deduped` is the one answer only the publish can give: a staged enqueue whose idempotency key a
664
+ LIVE job already holds creates nothing, and its ids then name no row. Give a run its own key
665
+ (a request id) when the caller keeps the ids.
666
+
615
667
  **It is not on by default, and it is not on until you install it** (`As of 2026-08`). Three
616
668
  things have to be true in a process:
617
669
 
@@ -657,8 +709,9 @@ What the lease buys, precisely:
657
709
  | It stops | It does not stop |
658
710
  |---|---|
659
711
  | two relays holding one batch — a committed row cannot be claimed twice inside its lease | the handler running twice |
660
- | 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 |
712
+ | a lapsed claimant releasing or retiring a newer claimant's rows | a re-publish after the job row was REMOVED (the id is free again) |
661
713
  | a relay that died mid-batch stranding its rows forever | anything a **non-idempotent** handler does on its second run |
714
+ | — | a lapsed visibility lease re-delivering a job that is still running |
662
715
 
663
716
  The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
664
717
  `retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
@@ -668,12 +721,14 @@ and the loop re-arms: an unobserved rejection would end the process with rows st
668
721
  `relay.stop()` is **async and joins the pass in flight** — `await` it before closing the database,
669
722
  the way `worker.stop()` and `scheduler.stop()` are awaited. A pass is a publish followed by a
670
723
  `markPublished`, and a caller that returned between the two closed the pool under the row it was
671
- about to mark.
724
+ about to mark. `relay.stop(deadlineAt)` binds the deadline LATE: a shutdown landing on a manual `stop()` bounds the wait already in flight, and the earliest deadline wins.
672
725
 
673
726
  ## Drivers
674
727
 
675
- One interface: `enqueue`, `claim` (visibility timeout), `ack`, `nack` (backoff),
676
- `heartbeat`, `stats`, plus optional `introspect`, `backfills` and `leases`. Zero job-code change
728
+ One interface: `enqueue`, `claim` (visibility timeout), `ack(jobId, { workerId, claim, durationMs? })`,
729
+ `nack(jobId, { workerId, claim, … })` (backoff), `heartbeat`, `stats`, plus optional `introspect`,
730
+ `backfills` and `leases`. Both settles are fenced on the CLAIM — `claimOf(claimed)` is the
731
+ `{ workerId, claim }` a `ClaimedJob` carries — and answer whether they landed. Zero job-code change
677
732
  between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driver` config line**:
678
733
  `JobsConfig.driver` has no reader and boot always builds `createPgDriver`.
679
734
 
@@ -687,6 +742,25 @@ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driv
687
742
  The pg SQL is exported verbatim (`SQL_CLAIM`, `SQL_ENQUEUE`, `SQL_NACK`, …) so an agent
688
743
  debugging a stuck queue can read and run the exact statement.
689
744
 
745
+ **A lease that lapses on a row's final attempt is buried by the claim.** `claim` re-takes a
746
+ `running` row whose lease lapsed — the worker died — but one already at `attempt >= maxAttempts`
747
+ is settled `dead` in the claim statement itself (`SQL_CLAIM`'s second arm; `isFinalAttempt` on the
748
+ memory driver), with `lastError` set to `LEASE_LAPSED_FINAL_ATTEMPT`, counted `dead`, and never
749
+ handed out: a job that kills its worker reaches the dead-letter queue after `retry.attempts`
750
+ claims instead of taking a worker per visibility timeout, forever. The pass is told through
751
+ `ClaimOptions.onExhausted(rows)` — at most once per pass, once per row — and `announceExhausted`
752
+ (`claim-exhausted.ts`) is what a claim round does with them: `jobs.claim.exhausted` at `error`, and
753
+ the job's `onSettled`, and the worker's claim round does exactly that. The row carries
754
+ `maxAttempts` and not the policy, so the CALLER names the jobs declaring `retry.deadLetter: false`
755
+ (`ClaimOptions.dropExhausted` — the worker reads them off its registry): those rows are buried
756
+ `failed` and announced `dropped`, every other one `dead` / `dead-lettered`. A caller that names
757
+ none (`x jobs drain`) buries every such row `dead`.
758
+
759
+ A driver you write reads a nack's target state through `nackState(options)` — `dead`
760
+ (`deadLetter`), `failed` (`fail`, terminal and out of the dead-letter queue), `suspended` (`park`)
761
+ or `ready` — so a new branch cannot land in one driver and not another. Its lease store implements
762
+ `holders(key)`: the live holders of one key, in slot order.
763
+
690
764
  ## Roles
691
765
 
692
766
  | Role | Entry | Behaviour |
@@ -732,7 +806,7 @@ import {
732
806
  } from '@ultimat3/jobs';
733
807
 
734
808
  declare const driver: JobDriver;
735
- declare const executor: PgExecutor; // `@ultimat3/cli`'s pgExecutorFor(client)
809
+ declare const executor: PgExecutor; // see "A `PgExecutor`" below
736
810
 
737
811
  createScheduler({
738
812
  driver,
@@ -774,6 +848,95 @@ too, so a scheduler back up after a day down enqueues one catch-up and then wait
774
848
  real occurrence. It used to leave the watermark on the occurrence it had just run, which made an
775
849
  hourly task fire 24 catch-ups a second apart.
776
850
 
851
+ ### A `PgExecutor`
852
+
853
+ `PgExecutor` is one method — `query(text, values)` — and the boot supplies it (`@ultimat3/cli`'s
854
+ `pgExecutorFor(client)`). Outside the boot — a test, a script — build it over `@ultimat3/db`'s
855
+ client, which already speaks `(text, values)` and decodes `jsonb`:
856
+
857
+ ```ts
858
+ import { createPostgresClient } from '@ultimat3/db';
859
+ import type { PgExecutor } from '@ultimat3/jobs';
860
+
861
+ const client = createPostgresClient({ url: 'postgres://localhost:5432/app_test' });
862
+ export const executor: PgExecutor = {
863
+ query: <R>(text: string, values: readonly unknown[]) => client.query<R>({ text, values }),
864
+ };
865
+ ```
866
+
867
+ | Not this | Because |
868
+ |---|---|
869
+ | `{ executor: Bun.sql }` | `Bun.sql` is a tagged template with no `.query(text, values)`: the first claim is a `TypeError` |
870
+ | a hand adapter over raw `Bun.SQL`'s `unsafe(text, values)` | observed `As of 2026-10`: a `$n::jsonb` parameter is encoded twice unless the client is built with `prepare: false`, and a JS array is refused where the statement expects `text[]` (`SQL_CLAIM`'s `queue = any($1::text[])`) unless it is written as an array literal. `@ultimat3/db`'s client handles both |
871
+
872
+ ## Idle cost and pickup latency
873
+
874
+ A queue with nothing to do costs almost nothing, and a job starts when it is committed — whichever process committed it.
875
+
876
+ | Loop | While there is work | While idle | Claims per idle minute |
877
+ |---|---|---|---|
878
+ | `worker` | a claim per queue every `pollIntervalMs` (250 ms) | the wait doubles to `idlePollMaxMs`; each pass is ONE claim over every queue with a free slot | 12 with a proven wake, 30 without (was 240 per queue) |
879
+ | outbox relay | a claim every `intervalMs` (200 ms) | the wait doubles to `idlePollMaxMs` | 12 with a proven wake, 30 without (was 300) |
880
+ | `scheduler` | one round every `tickIntervalMs` (1 s) | a round reads no store and resolves no cron: watermarks are held in memory while it leads, the lease is renewed every `renewEveryMs` (a third of its TTL — 10 s) | 6 statements, for any number of tasks (was 2T+1 a second: 4,321 a minute at 35 tasks) |
881
+
882
+ `idlePollMaxMs` defaults to `IDLE_POLL_CEILING_MS` (2 s), and to `WOKEN_IDLE_POLL_CEILING_MS` (5 s) while `wakeIsLive()`. Declared, it is the ceiling either way.
883
+
884
+ ### The wake
885
+
886
+ `startQueueWake({ listener, executor })` holds ONE `LISTEN` session per worker pod and turns "another process committed" into the signal the idle loops already hear. The boot starts it for the `worker` role.
887
+
888
+ ```ts
889
+ import { createPostgresClient } from '@ultimat3/db';
890
+ import type { PgExecutor } from '@ultimat3/jobs';
891
+ import { startQueueWake } from '@ultimat3/jobs';
892
+
893
+ const client = createPostgresClient({ url: 'postgres://localhost:5432/app_test' });
894
+ const executor: PgExecutor = {
895
+ query: <R>(text: string, values: readonly unknown[]) => client.query<R>({ text, values }),
896
+ };
897
+ // `client.listen` is the session; the pooled executor sends the probe that proves it.
898
+ export const wake = startQueueWake({ listener: client, executor });
899
+ ```
900
+
901
+ | Statement | Announces | Stays silent when |
902
+ |---|---|---|
903
+ | `SQL_ENQUEUE` | `pg_notify('x_jobs_wake', <queue>)` | another row of that queue was created in the same `WAKE_SLOT_MS` (250 ms) slot; the row is due more than `WAKE_DUE_WITHIN_MS` (1 s) out; the idempotency key deduped |
904
+ | `SQL_OUTBOX_STAGE` | `pg_notify('x_outbox_wake', '')`, delivered on COMMIT | a committed row is already waiting unclaimed |
905
+ | `SQL_SCHEDULER_FIRE` | each queue the occurrence filled, once | the watermark refused the fire |
906
+ | `SQL_JOB_REQUEUE`, `SQL_JOB_PROMOTE`, `requeueMany`, `resumeQueue` | the queue | — |
907
+
908
+ | Rule | Why |
909
+ |---|---|
910
+ | the payload is the queue name, never an id, an input or a tenant | every session on the channel reads it |
911
+ | a notification is throttled in the statement | Postgres serialises the commit of every transaction that issued a `NOTIFY` behind one lock, held through its WAL flush. One per enqueue caps enqueuing commits at one flush at a time |
912
+ | a woken loop passes now, then at the floor again | that pass is what finds the row its slot kept silent — so pickup is never worse than the fixed 250 ms poll |
913
+ | the wake is PROVEN before the ceiling rises | a probe sent through the pool has to come back on the session (`jobs.wake.live`). One that does not is `jobs.wake.unverified`, once, with the fix |
914
+ | the poll is the guarantee | a notification is lost with the session that would have carried it. The driver re-dials (`jobs.wake.live` again); meanwhile jobs start on the poll |
915
+ | a transaction-pooling proxy delivers nothing | PgBouncer `pool_mode = transaction`: the `LISTEN` lands on a connection the proxy takes back. The wake stays unproven and the ceiling stays 2 s — give the `worker` role a direct or session-pooled `DATABASE_URL` |
916
+ | the memory driver needs none | one process: `signalEnqueued` is the whole wake |
917
+
918
+ | A job enqueued by | Starts after — measured, Postgres 17 on loopback, idle worker, `As of 2026-10-01` |
919
+ |---|---|
920
+ | this process — a direct enqueue, the relay's publish, the scheduler's fire | at once (`signalEnqueued`): median 10 ms, max 19 ms |
921
+ | another process, direct | median 8 ms, max 18 ms (was median 1,033 ms, max 1,785 ms: the 2 s poll) |
922
+ | another process, through the outbox — from its COMMIT | median 9 ms, max 13 ms (was median 1,275 ms, max 2,008 ms) |
923
+ | another process, no wake (proxy, lost session) | within `idlePollMaxMs` — 2 s |
924
+ | nobody — a retry or a `step.sleep` this worker handed back | when it falls due: the worker keeps a timer for a row due inside a minute (`JobExecution.resumeAt`) |
925
+ | nobody — a delayed job another process queued | within `idlePollMaxMs` past its `runAt` |
926
+
927
+ | Rule | Detail |
928
+ |---|---|
929
+ | a slot coming free is refilled at once | a full worker asks the queue for nothing, so its loop backs off; the job that finishes kicks one pass |
930
+ | a wake on a pass in flight | asks for ONE more pass behind it, never a second loop |
931
+ | a wake naming a queue the worker does not serve | ignored |
932
+ | a pause is read by the claim itself | `pauseQueue` stops claims at each worker's NEXT claim — no poll to wait for. `resumeQueue` announces the queue |
933
+ | a paused task | checked when it is due, so the pause holds for the very next occurrence; a task found paused is looked at again after `PAUSE_RECHECK_MS` (5 s), which is the resume's latency |
934
+ | a standby scheduler | asks for the lease every `renewEveryMs`, not every round |
935
+ | a queue no registered job names | still polled — a per-call `enqueue({ queue })` may land there — and reported once at boot as `jobs.worker.queue-unregistered`; idle, it adds nothing to the one claim |
936
+ | an enqueuer whose clock runs ahead of the database's | its "immediate" jobs are due a moment from now (`run_at` is the enqueuer's clock); up to 1 s ahead they are still announced, past that they start on the poll |
937
+
938
+ Lower `idlePollMaxMs` to trade idle load for the no-wake bound; there is no flag for the old fixed interval.
939
+
777
940
  ## Retries
778
941
 
779
942
  `{ attempts, backoff: 'exponential' | 'linear' | 'fixed', delay, maxDelay, jitter }`.
@@ -833,11 +996,79 @@ cannot hold the cap, so `createWorker().start()` **refuses to boot**
833
996
  Over any cap the claim is handed straight back without burning an attempt — one org's 50k-row
834
997
  import cannot starve the fleet.
835
998
 
999
+ ### One run per key
1000
+
1001
+ A number caps the whole job. `{ key, limit, whenBusy }` caps it **per key** — "at most one run
1002
+ per account, fleet-wide" is a declared field, not three guards in app code:
1003
+
1004
+ ```ts
1005
+ import { job, t } from '@ultimat3/jobs';
1006
+
1007
+ export const syncAccount = job({
1008
+ input: t.object({ accountId: t.uuid, orgId: t.uuid }),
1009
+ tenant: ({ orgId }) => orgId,
1010
+ idempotencyKey: ({ accountId }) => `sync:${accountId}`,
1011
+ retry: { attempts: 3 },
1012
+ concurrency: { key: ({ accountId }) => accountId, limit: 1, whenBusy: 'fail' },
1013
+ async run({ input, step, finalAttempt }) {
1014
+ await step.run('sync', () => ({ accountId: input.accountId, lastTry: finalAttempt }));
1015
+ },
1016
+ });
1017
+ ```
1018
+
1019
+ | Field | Rule |
1020
+ |---|---|
1021
+ | `key(input)` | the lease this run counts under. Deterministic from `input` only; a non-empty string of at most `MAX_CONCURRENCY_KEY_LENGTH` (200) characters — an id, never a payload |
1022
+ | `limit` | max in-flight runs sharing one key. A whole number, 1 or more |
1023
+ | `whenBusy: 'wait'` (default) | the run stays `ready`, no attempt burned, claimed again once a slot frees |
1024
+ | `whenBusy: 'fail'` | the run settles **`failed`** with `X_JOB_KEY_BUSY` in `lastError`; its body never runs, nothing retries it, and it is **not** a dead letter. `onSettled` is told (`outcome: 'refused'`). `x jobs retry <id>` re-queues it |
1025
+ | no `duration` | the bound after which a stuck holder stops blocking its key is the lease TTL — the worker's `visibilityTimeoutMs`, renewed every `heartbeatIntervalMs`. A second number could disagree with it |
1026
+
1027
+ | Refused | Where | Code |
1028
+ |---|---|---|
1029
+ | a cap of `0`, negative, fractional, `NaN` or `Infinity` — `concurrency: 0` and `limit: 0` alike; a `key` that is not a function; a `whenBusy` outside the two | where the job is declared | `X_JOB_DECLARATION_INVALID` |
1030
+ | `key(input)` answering `''`, a non-string, or more than 200 characters | at that **enqueue** — the facade and the scheduler both ask | `X_JOB_DECLARATION_INVALID` |
1031
+ | a driver with no lease store, for any `concurrency` | `createWorker().start()` | `X_JOB_CONCURRENCY_UNENFORCEABLE` |
1032
+ | a `LeaseStore` with no `holders(key)` | the build — the member is required | `TS2741` |
1033
+
1034
+ What `'fail'` decides, exactly:
1035
+
1036
+ | Case | Answer |
1037
+ |---|---|
1038
+ | another run holds the key | refused — `failed`, `X_JOB_KEY_BUSY`, `JobOutcome` `refused`, `WorkerStats.refused` |
1039
+ | the only holder is **this run's own** earlier claim (a retry, a `step.sleep` resume or a redelivery meeting the slot its last claim has not yet released or let lapse) | waits one poll — a run is never failed by its own leftovers. This is what `LeaseStore.holders()` is read for |
1040
+ | the key was busy at the acquire and free a moment later | waits one poll |
1041
+ | the holder's worker was SIGKILLed | the key stays busy until the lease TTL passes, then frees with no cleanup call |
1042
+ | `key(input)` throws at claim, or the stored input no longer parses | that **attempt** fails without the body running and takes the ordinary retry path — never the claim round |
1043
+
1044
+ The decision is taken at **every claim**, so a retry or a resumed run claims its key again: under
1045
+ `'fail'`, a different run that took the key in between refuses it.
1046
+
1047
+ The lease row is keyed `job-key:<encoded job name>:<key>` (`jobLeaseKey(name, key)`), apart from
1048
+ the plain cap's `job:<name>` so no job name can collide with another job's key.
1049
+
1050
+ ### `finalAttempt`
1051
+
1052
+ `run({ finalAttempt })` is `true` when a failure of this attempt is not retried for want of
1053
+ attempts — `isFinalAttempt(retry, attempt)`, the one comparison the runner dead-letters on, so a
1054
+ body never re-derives it from `retry.attempts`.
1055
+
1056
+ | Not | Because |
1057
+ |---|---|
1058
+ | "the body runs at most once more" | a `terminal` error stops an earlier attempt |
1059
+ | true exactly once per job | an attempt handed back uncounted — `step.sleep`, a drain — is presented again under the same number |
1060
+
1061
+ `finalAttempt` is a REQUIRED member of `JobRunArgs`: a test that drives a body by hand —
1062
+ `handle.run({ input, step, ctx, attempt, finalAttempt, jobId, runId })` — states it, and
1063
+ `isFinalAttempt(handle.retry, attempt)` is the worker's own answer.
1064
+
836
1065
  ## Leases
837
1066
 
838
1067
  A claim buys `visibilityTimeoutMs` of invisibility; the worker renews it every
839
- `heartbeatIntervalMs` (default a third of the window) for as long as the job runs. Renewal
840
- failures are not swallowed:
1068
+ `heartbeatIntervalMs` (default a third of the window) for as long as the job runs. Every renewal —
1069
+ the lease, a fleet slot, the worker's registry row — is armed through `schedule`
1070
+ (`IntervalScheduler`, default a real unrefed interval); `@ultimat3/testing`'s `runJobs` hands one
1071
+ driven by the frozen clock. Renewal failures are not swallowed:
841
1072
 
842
1073
  | Fact | Signal |
843
1074
  |---|---|
@@ -856,10 +1087,104 @@ already **on the wire**, not only for the next one: a clean completion acks the
856
1087
  pool was slow enough. The fleet slot's `jobs.worker.slot-lost` had the same shape and the same
857
1088
  fix (`renewal-timer.ts`).
858
1089
 
1090
+ ## The operator surface
1091
+
1092
+ Every operator capability is a member of `JobIntrospection` (`driver.introspect`), implemented by
1093
+ the pg and the memory driver alike. `x jobs`, `/_x` and a dashboard read nothing else.
1094
+
1095
+ | Member | Does | Bound |
1096
+ |---|---|---|
1097
+ | `list(filter)` | one page, newest first; `filter.after = jobCursor(lastRow)` for the next, `filter.before = jobCursor(firstRow)` for the previous (the rows nearest the cursor, still newest first; never both). Filters: `queue`, `name`, `state`, `tenantId`, `idPrefix`, `createdFrom`, `createdTo` | `MAX_JOB_PAGE` = 200 rows; `DEFAULT_JOB_PAGE` = 100. A page shorter than its limit is the last. A larger `limit`, or a cursor no page produced, is `X_JOB_PAGE_INVALID` |
1098
+ | `remove(id)` | delete one job and its steps. A running one is `X_JOB_NOT_REMOVABLE` | — |
1099
+ | `requeue(id, { fromStep? })` | re-queue ONE finished job (`REQUEUEABLE_STATES`). A live one is `X_JOB_NOT_REQUEUEABLE`, a key a live job holds `X_JOB_DUPLICATE`, an id nobody queued `X_JOB_NOT_FOUND` — on both drivers. `fromStep` drops that step and every later one in the statement that moves the row | — |
1100
+ | `cancel(id, reason?)` | stop a LIVE job (`LIVE_STATES`: ready, delayed, running, suspended). A done, failed, dead or cancelled row is left exactly as it ended and answers `undefined` — `cancelJob` turns that into `X_JOB_NOT_CANCELLABLE` | — |
1101
+ | `requeueMany({ state, queue?, name?, tenantId? })` | re-queue finished rows; a row whose key a live job holds stays, and of several finished rows sharing one key the oldest is requeued | `MAX_BULK_ROWS` = 1,000 per call, spent only on rows it can move; answers `{ affected, remaining }`, where `remaining` is what a second call could still move — a held row is not counted, so "call until zero" ends |
1102
+ | `removeMany({ state, queue?, name?, tenantId? })` | delete rows in any state but `running` | the same |
1103
+ | `promoteMany({ state, queue?, name?, tenantId? })` | `promote` over a set: every row still waiting on its `runAt` is due now. `state` is `delayed` or `ready` (`PROMOTABLE_STATES`) | the same; `remaining` is the rows still waiting |
1104
+ | `promote(id)` | a job waiting on its `runAt` — delayed, or backing off — is due now | — |
1105
+ | `pauseQueue(q)` / `resumeQueue(q)` / `pausedQueues()` | a paused queue is claimed by no worker; enqueues still land | effective at every worker's next claim |
1106
+ | `pauseTask(t)` / `resumeTask(t)` / `pausedTasks()` | a paused task is not dispatched; on resume its own `catchUp` decides what it missed | — |
1107
+ | `workers()` | every live worker: id, host, started, queues, slots, in-flight job ids, last heartbeat | rows expire one visibility timeout after the last heartbeat; `MAX_WORKER_IN_FLIGHT` = 100 ids |
1108
+ | `counters({ job, sinceMs })` / `counterTotals(sinceMs)` | per-job history: `done`, `retried`, `failed`, `dead`, `durationMs` per bucket | `COUNTER_TIERS`: 1-minute buckets for 24 h, 5-minute for 7 d, 1-hour for 30 d — 4,176 rows per job name at most |
1109
+ | `rollupCounters()` | fold buckets past their tier into the next; the scheduler leader calls it once a minute | idempotent |
1110
+ | `recordProgress(id, { workerId, claim }, progress)` | what `run({ progress })` writes | at most one write per `PROGRESS_INTERVAL_MS` (1 s) per run, plus the last before the settle |
1111
+ | `taskFires()` | per task, the last occurrence that DISPATCHED: `{ task, occurrenceMs, firedAt }`, by task name. Arming a task or skipping missed occurrences moves the watermark and is not a fire. The NEXT fire is `nextTaskRun(task, from)` — the scheduler's own resolver, no scheduler needed | `MAX_TASK_FIRES` = 1,000 rows |
1112
+ | `recordTaskFire({ task, occurrenceMs })` | the scheduler's record of a fire that went through `driver.enqueue`; the pg fire writes it in `SQL_SCHEDULER_FIRE` | — |
1113
+
1114
+ | Rule | Why |
1115
+ |---|---|
1116
+ | the counter moves in the statement that settles the row | a count that can disagree with the rows is worse than none — and no second round trip per job |
1117
+ | a shed, a suspension and a drained attempt add nothing | they are handed back uncounted; counted, every `step.sleep` would read as a failure |
1118
+ | `ack` / `nack` / `heartbeat` / `recordProgress` are fenced on the CLAIM | `{ workerId, claim }`: a body whose lease lapsed must not settle, renew or report on the run that replaced it — claimed by another worker, or by the SAME one, whose id did not change. A miss answers `false` and logs `jobs.settle.unowned` |
1119
+ | `ack(id, { …, counted: false })` adds nothing to the counters | `x jobs drain` settles the row it moved: `done` here, and not a completed run |
1120
+ | a pause is a row the claim reads | never a column on every queued row: pausing a million-row queue takes effect within one poll |
1121
+ | an occurrence fires in one statement | the watermark and its jobs move together (`SchedulerState.fire`), so a crash between them cannot fire it twice |
1122
+
1123
+ `run({ progress })` reports how far a run has got — `progress(done, total, note?)`, as often as
1124
+ the loop turns.
1125
+
1126
+ ### `onSettled` — how a run ended
1127
+
1128
+ ONE hook, told once per ending. There is no `onDead` and no `onComplete` beside it.
1129
+
1130
+ ```ts
1131
+ import { job, t } from '@ultimat3/jobs';
1132
+
1133
+ /** The app's own reactions — tenant-scoped writes, which is why the hook runs under the tenant. */
1134
+ const markSynced = (_accountId: string, _rows: number): Promise<void> => Promise.resolve();
1135
+ const markBroken = (_accountId: string, _code: string | undefined): Promise<void> =>
1136
+ Promise.resolve();
1137
+
1138
+ export const syncAccountWatched = job({
1139
+ input: t.object({ accountId: t.uuid, orgId: t.uuid }),
1140
+ tenant: ({ orgId }) => orgId,
1141
+ idempotencyKey: ({ accountId }) => `sync:${accountId}`,
1142
+ retry: { attempts: 3 },
1143
+ concurrency: { key: ({ accountId }) => accountId, limit: 1, whenBusy: 'fail' },
1144
+ async run({ input, progress }) {
1145
+ progress(0, 2, `account ${input.accountId}`);
1146
+ progress(2, 2);
1147
+ return { rows: 2 };
1148
+ },
1149
+ async onSettled(settled) {
1150
+ if (settled.outcome === 'completed') {
1151
+ await markSynced(settled.input.accountId, settled.result.rows);
1152
+ } else if (settled.input !== undefined) {
1153
+ await markBroken(settled.input.accountId, settled.code);
1154
+ }
1155
+ },
1156
+ });
1157
+ ```
1158
+
1159
+ | The run ended | `outcome` | Carries | Row state when the hook runs |
1160
+ |---|---|---|---|
1161
+ | the body returned and the ack landed | `completed` | `input`, `result` — what `run` returned, typed from it | `done` |
1162
+ | failed for good, parked in the dead-letter queue | `dead-lettered` | `input` (or `undefined`: the stored payload no longer parses), `error`, `code` | `dead` |
1163
+ | its lease lapsed on the final attempt and the claim buried it — every worker that took it died | `dead-lettered` | `input`, `error` (`LEASE_LAPSED_FINAL_ATTEMPT`), `code: undefined` — nothing was thrown | `dead` |
1164
+ | failed for good on `retry.deadLetter: false` | `dropped` | the same | `failed` |
1165
+ | `whenBusy: 'fail'` over a busy key — the body never ran | `refused` | the same, `code: 'X_JOB_KEY_BUSY'` | `failed` |
1166
+
1167
+ | Not a settlement | Who observes it instead |
1168
+ |---|---|
1169
+ | a retry, a `step.sleep` / `waitForEvent`, a drain | nobody: the run has not ended |
1170
+ | `cancelJob()` / `x jobs cancel` on a queued or running row | the caller of the cancel — the worker's own settle then matches nothing |
1171
+ | `x jobs retry`, `rm`, `promote`, `drain` | the operator; a retried run settles again, and is told again |
1172
+ | a settle that did not land — the lease lapsed and the queue re-delivered | the claim that holds the row now |
1173
+
1174
+ | Guarantee | Stated plainly |
1175
+ |---|---|
1176
+ | when | AFTER the row is settled, by the worker whose settle landed, under the job's declared tenant; `settled.ctx` is that context |
1177
+ | how often | **at most once** per ending across a crash: a worker killed between the settle and the hook never runs it. Not at-least-once — nothing re-drives a hook |
1178
+ | inside one process | `ON_SETTLED_ATTEMPTS` (3) tries, back to back. A hook that threw halfway is run again from the top, so write it idempotent |
1179
+ | a hook that keeps throwing | logged `jobs.on-settled.failed` and reported as `X_JOB_ON_SETTLED_FAILED`. The row, the outcome, the counters and `JobExecution` are untouched |
1180
+ | `result` | the value itself, in the worker's process — never stored, never serialised. `JobExecution.result` carries the same value to a caller driving a run by hand |
1181
+ | what must be recorded for certain | written by the BODY, inside a `step.run` — that is at-least-once, replayed from storage. The hook is for a notification, a status column, a usage line |
1182
+
859
1183
  ## Introspection
860
1184
 
861
- `inspectQueues`, `inspectJob` (per-step trace), `inspectDeadLetters`, `retryFromStep`,
862
- `cancelJob`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
1185
+ `inspectQueues`, `inspectJob` (per-step trace, the redacted `input`, the failure's `stack`, the
1186
+ last `progress`), `inspectDeadLetters`, `retryFromStep`, `cancelJob`, `removeJob`, `promoteJob`,
1187
+ `pauseQueue`, `resumeQueue`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
863
1188
 
864
1189
  `cancelJob(driver, id, reason?)` is the answer to a runaway pass. A queued row becomes `cancelled`
865
1190
  immediately; a RUNNING one stops at its next heartbeat, which no longer matches its own row and
@@ -901,8 +1226,15 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
901
1226
  | `X_ABORTED` | a cancelled attempt tried to write a step — core's code, not a second name for it |
902
1227
  | `X_JOB_LEASE_LOST` | the job was cancelled, or its lease lapsed and the queue re-delivered it, while this worker was still running it |
903
1228
  | `X_JOB_SLOT_LOST` | the fleet `concurrency` slot this run held was taken by another worker — a different row on a different clock from the lease above |
904
- | `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that already finished, or a driver with no `cancel` |
905
- | `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency` and the driver has no lease store |
1229
+ | `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that is not live — done, failed, dead or already cancelled — an id no queue holds, or a driver with no `cancel` |
1230
+ | `X_JOB_NOT_FOUND` | `requeue` was asked for an id the queue does not hold — one answer on both drivers. Fix: `x jobs ls --json` |
1231
+ | `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency`, plain or keyed, and the driver has no lease store |
1232
+ | `X_JOB_KEY_BUSY` | a run claimed under `whenBusy: 'fail'` while its key already held `limit` runs. Classified `terminal`. Fix: `x jobs ls --name <job> --state running --json` |
1233
+ | `X_JOB_DECLARATION_INVALID` | `job()` is missing a required field, declares a `concurrency` no worker can honour (plain or keyed), or its `concurrency.key` answered an empty, non-string or over-long key at enqueue |
1234
+ | `X_JOB_NOT_REMOVABLE` | `remove` / `removeMany` reached a running job. Fix: `x jobs cancel <id> --json` |
1235
+ | `X_JOB_NOT_PROMOTABLE` | `promote` reached a job not waiting on its run time |
1236
+ | `X_JOB_PAGE_INVALID` | `list()` was asked for more than `MAX_JOB_PAGE` rows, or handed a cursor no page produced. The cause names which cursor (`after` / `before`). Fix: `x jobs ls --limit 200 --json` |
1237
+ | `X_JOB_ON_SETTLED_FAILED` | a declared `onSettled` threw on every one of its tries; logged and reported, never thrown |
906
1238
  | `X_NOT_IMPLEMENTED` | redis / nats driver |
907
1239
 
908
1240
  ### Error classes
@@ -922,22 +1254,29 @@ a job boundary the class is gone and the `code` is what survives — match on th
922
1254
  | `BackfillUnknownError` | `X_BACKFILL_UNKNOWN` | `src/backfill-errors.ts` |
923
1255
  | `CancelUnsupportedError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
924
1256
  | `ClaimQueuesEmptyError` | `X_JOB_CLAIM_QUEUES_EMPTY` | `src/errors.ts` |
925
- | `ConcurrencyUnenforceableError` | `X_JOB_CONCURRENCY_UNENFORCEABLE` | `src/errors.ts` |
1257
+ | `ConcurrencyUnenforceableError` | `X_JOB_CONCURRENCY_UNENFORCEABLE` | `src/errors-concurrency.ts` |
926
1258
  | `DriverUnavailableError` | `X_DRIVER_UNAVAILABLE` | `src/errors.ts` |
927
1259
  | `ExportPartTooLargeError` | `X_EXPORT_PART_TOO_LARGE` | `src/export-errors.ts` |
928
1260
  | `ExportRowInvalidError` | `X_EXPORT_ROW_INVALID` | `src/export-errors.ts` |
929
1261
  | `IdempotencyRequiredError` | `X_IDEMPOTENCY_REQUIRED` | `src/errors.ts` |
930
1262
  | `JobAbortedError` | `X_ABORTED` | `src/errors.ts` |
1263
+ | `JobConcurrencyInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-concurrency.ts` |
1264
+ | `JobConcurrencyKeyInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-concurrency.ts` |
931
1265
  | `JobDeclarationInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-declaration.ts` |
932
1266
  | `JobDrainedError` | `X_DRAINING` | `src/errors.ts` |
933
1267
  | `JobDuplicateError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
1268
+ | `JobKeyBusyError` | `X_JOB_KEY_BUSY` | `src/errors-concurrency.ts` |
934
1269
  | `JobMaxAttemptsError` | `X_JOB_MAX_ATTEMPTS` | `src/errors.ts` |
935
1270
  | `JobNameTakenError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
936
1271
  | `JobNotCancellableError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
1272
+ | `JobNotFoundError` | `X_JOB_NOT_FOUND` | `src/errors-requeue.ts` |
1273
+ | `JobNotPromotableError` | `X_JOB_NOT_PROMOTABLE` | `src/errors-operator.ts` |
1274
+ | `JobNotRemovableError` | `X_JOB_NOT_REMOVABLE` | `src/errors-operator.ts` |
937
1275
  | `JobNotRequeueableError` | `X_JOB_NOT_REQUEUEABLE` | `src/errors-requeue.ts` |
1276
+ | `JobOnSettledFailedError` | `X_JOB_ON_SETTLED_FAILED` | `src/errors-operator.ts` |
1277
+ | `JobPageInvalidError` | `X_JOB_PAGE_INVALID` | `src/errors-operator.ts` |
938
1278
  | `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
939
1279
  | `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
940
- | `JobsNotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
941
1280
  | `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
942
1281
  | `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
943
1282
  | `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "22.15.0",
3
+ "version": "24.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,10 +32,10 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "22.15.0",
36
- "@ultimat3/db": "22.15.0",
37
- "@ultimat3/entity": "22.15.0",
38
- "@ultimat3/schema": "22.15.0",
39
- "@ultimat3/time": "22.15.0"
35
+ "@ultimat3/core": "24.0.0",
36
+ "@ultimat3/db": "24.0.0",
37
+ "@ultimat3/entity": "24.0.0",
38
+ "@ultimat3/schema": "24.0.0",
39
+ "@ultimat3/time": "24.0.0"
40
40
  }
41
41
  }
@@ -10,7 +10,7 @@
10
10
  // become — the checkpoints are transactional with the work, and this row is not.
11
11
 
12
12
  import type { Clock } from '@ultimat3/core';
13
- import { finiteCount, systemClock } from '@ultimat3/core';
13
+ import { describeValue, finiteCount, systemClock, UltimateError } from '@ultimat3/core';
14
14
  import { nowMs } from './clock';
15
15
 
16
16
  /**
@@ -56,6 +56,27 @@ export interface BackfillFilter {
56
56
  readonly limit?: number | undefined;
57
57
  }
58
58
 
59
+ /** What Postgres prints for a `uuid` — the one spelling both ledgers store identically. */
60
+ const RUN_ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
61
+
62
+ /**
63
+ * `BackfillFilter.runId`, refused by BOTH ledgers' `list` when it is not a uuid. `run_id` is a
64
+ * `uuid` column: Postgres raised a raw `22P02` where the memory ledger answered an empty page, so
65
+ * a mistyped id read as "no such pass" under `x dev` and as a database fault in production. The
66
+ * shape `outbox.ts` holds an enqueue's `runId` to — lowercase, so the two never disagree on case.
67
+ */
68
+ export function listedRunId(subject: string, runId: unknown): string | undefined {
69
+ if (runId === undefined) return undefined;
70
+ if (typeof runId === 'string' && RUN_ID_SHAPE.test(runId)) return runId;
71
+ throw new UltimateError({
72
+ code: 'X_ID_INVALID',
73
+ // `describeValue`, never the value: what arrives here wrong is as often a key as a typo.
74
+ cause: `${subject} backfills.list was filtered by runId ${describeValue(runId)}, and a run id is a lowercase uuid (8-4-4-4-12 hex) — x_backfills.run_id is a uuid column`,
75
+ fix: 'x db backfill --list --json',
76
+ meta: { option: 'runId' },
77
+ });
78
+ }
79
+
59
80
  /**
60
81
  * Durable, and therefore behind the queue driver: `x_backfills` ships in the same DDL as `x_jobs`
61
82
  * and `x_job_steps` (`driver-pg-sql.ts`), so a ledger a pass cannot write is a queue it could not
@@ -169,6 +190,7 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
169
190
  },
170
191
  // `async`, so a refused limit REJECTS here as it does on the pg ledger — see `driver-memory.ts`.
171
192
  async list(filter = {}) {
193
+ const runId = listedRunId('the memory ledger', filter.runId);
172
194
  // Reversed BEFORE the sort: the test clock is frozen, so two rows share a `startedAt` and a
173
195
  // stable sort would hand back the oldest of them first under a "newest first" contract.
174
196
  const rows = [...runs.values()]
@@ -176,7 +198,7 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
176
198
  .sort((a, b) => b.startedAt - a.startedAt)
177
199
  .filter((run) => filter.name === undefined || run.name === filter.name)
178
200
  .filter((run) => filter.status === undefined || run.status === filter.status)
179
- .filter((run) => filter.runId === undefined || run.runId === filter.runId)
201
+ .filter((run) => runId === undefined || run.runId === runId)
180
202
  .slice(0, finiteCount('the backfill ledger list', 'limit', filter.limit ?? 100));
181
203
  return rows;
182
204
  },