@ultimat3/jobs 22.15.0 → 23.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 (63) hide show
  1. package/CLAUDE.md +129 -87
  2. package/README.md +329 -19
  3. package/package.json +6 -6
  4. package/src/concurrency.ts +144 -0
  5. package/src/counters.ts +105 -0
  6. package/src/describe.ts +29 -0
  7. package/src/driver-memory-operator.ts +297 -0
  8. package/src/driver-memory.ts +79 -37
  9. package/src/driver-nats.ts +2 -2
  10. package/src/driver-pg-ddl.ts +71 -0
  11. package/src/driver-pg-jobs-sql.ts +71 -8
  12. package/src/driver-pg-operator-sql.ts +303 -0
  13. package/src/driver-pg-operator.ts +300 -0
  14. package/src/driver-pg-outbox-sql.ts +110 -0
  15. package/src/driver-pg-rows.ts +31 -0
  16. package/src/driver-pg-settle-sql.ts +71 -0
  17. package/src/driver-pg-sql.ts +80 -129
  18. package/src/driver-pg-wake-sql.ts +76 -0
  19. package/src/driver-pg.ts +74 -34
  20. package/src/driver-redis.ts +2 -2
  21. package/src/driver.ts +96 -34
  22. package/src/enqueue-signal.ts +50 -0
  23. package/src/errors-concurrency.ts +91 -0
  24. package/src/errors-operator.ts +94 -0
  25. package/src/errors.ts +13 -17
  26. package/src/events-pg.ts +38 -28
  27. package/src/events.ts +25 -0
  28. package/src/execute.ts +160 -81
  29. package/src/export.ts +23 -4
  30. package/src/heartbeat.ts +9 -3
  31. package/src/idle-backoff.ts +74 -0
  32. package/src/index.ts +105 -12
  33. package/src/inspect-operator.ts +49 -0
  34. package/src/inspect.ts +42 -2
  35. package/src/introspection.ts +262 -0
  36. package/src/job.ts +78 -15
  37. package/src/leases.ts +49 -9
  38. package/src/metrics.ts +6 -1
  39. package/src/outbox-pg.ts +3 -0
  40. package/src/outbox-relay.ts +114 -39
  41. package/src/outbox.ts +29 -6
  42. package/src/progress.ts +78 -0
  43. package/src/queue-wake.ts +220 -0
  44. package/src/redact-input.ts +26 -0
  45. package/src/renewal-timer.ts +28 -10
  46. package/src/retry-classification.ts +13 -1
  47. package/src/retry.ts +10 -2
  48. package/src/run-deadline.ts +57 -0
  49. package/src/scheduler-leader.ts +26 -0
  50. package/src/scheduler-occurrences.ts +76 -0
  51. package/src/scheduler-pg.ts +55 -2
  52. package/src/scheduler-state.ts +66 -0
  53. package/src/scheduler.ts +143 -103
  54. package/src/settled.ts +161 -0
  55. package/src/worker-admit.ts +163 -0
  56. package/src/worker-fleet-slots.ts +107 -38
  57. package/src/worker-hand-back.ts +6 -1
  58. package/src/worker-key-busy.ts +79 -0
  59. package/src/worker-loop.ts +145 -0
  60. package/src/worker-registry.ts +103 -0
  61. package/src/worker-run.ts +9 -0
  62. package/src/worker-types.ts +23 -0
  63. package/src/worker.ts +103 -134
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,16 @@ 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.
200
+
191
201
  Step names are the replay key, so they must be deterministic and unique in a run — a
192
202
  duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
193
203
  (`StepSuspension`), never a failure: it does not burn a retry attempt.
@@ -440,7 +450,7 @@ export const exportOrders = exportRows({
440
450
  placedAt: formatDate(instant(order.placedAt), { locale: 'en', zone: 'UTC' }),
441
451
  total: formatMoney(order.total, 'en'),
442
452
  }),
443
- sink: disk('exports'), // a StorageDriver already IS an ExportSink
453
+ sink: () => disk('exports'), // a thunk: read per write, after defineStorage() — never at import
444
454
  });
445
455
  ```
446
456
 
@@ -566,7 +576,9 @@ there, as before. A manual `stop()` aborts nothing: it waits for the work it hol
566
576
  | `step.sleep` / `step.waitForEvent` | `suspended` | no | not counted |
567
577
  | the body throwing, attempts left | `retried` | yes | `failed` |
568
578
  | the body throwing, none left or `terminal` | `dead-lettered` | yes | `dead` |
579
+ | the same, on a job declaring `retry.deadLetter: false` | `dropped` — the row is `failed` | yes | `failed` |
569
580
  | the worker's drain (`X_DRAINING` on `ctx.signal`) | `interrupted` | **no** | not counted |
581
+ | `whenBusy: 'fail'` over a busy concurrency key — the body never started | `refused` | **no** | `failed` |
570
582
 
571
583
  Three ceilings, declared on the job and nowhere else (`As of 2026-08` — `stepTimeout` and
572
584
  `eventPoll` had been implemented in the step runner since 1.0 with no declaration able to reach
@@ -601,17 +613,46 @@ it after commit. The bug class this removes:
601
613
 
602
614
  Both are load-dependent, both pass every test you would write, and both produce "the email
603
615
  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
616
+ The relay publishes *then* marks published, so a crash re-publishes — and **that repeat meets
617
+ the job the first publish made** (`As of 2026-10`): a staged row is published under its own id
618
+ (`EnqueueRequest.id`), and `SQL_ENQUEUE` inserts nothing under an id that already names a row,
619
+ live or finished. Until then the repeat was collapsed only while the first job was still live —
620
+ the conflict target is a partial index over `ready`/`delayed`/`running`/`suspended` — so one
621
+ landing after it finished ran the handler again. A job row an operator removed is the one case
622
+ left. **Handlers are still at-least-once** — a lease that lapses re-delivers — **so write them
623
+ 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
624
  overtake it: `claim()` returns rows in `staged_at, id` order — total, so two relays compose the
611
625
  same batch in the same order — and an app that stages `createInvoice` then `chargeCard` in one
612
626
  transaction must never have the charge run first. Set `mode: 'required'` to
613
627
  make an enqueue outside a transaction an `X_OUTBOX_NO_TX` error instead of a direct publish.
614
628
 
629
+ **The ids are known at enqueue time, on every path** (`As of 2026-10`). A staged enqueue used to
630
+ answer `runId: ''` and the outbox row's id — neither names a job — so an action that had to say
631
+ which run it started had to pass `outbox: false` and give the transaction up. Both ids are
632
+ allocated when the row is staged (`x_outbox.run_id`, and the row id IS the job id), so:
633
+
634
+ ```ts
635
+ import { job, t } from '@ultimat3/jobs';
636
+
637
+ export const syncAccount = job({
638
+ input: t.object({ accountId: t.uuid, orgId: t.uuid, requestId: t.uuid }),
639
+ tenant: ({ orgId }) => orgId,
640
+ idempotencyKey: ({ requestId }) => `sync:${requestId}`,
641
+ retry: { attempts: 1 },
642
+ async run() {},
643
+ });
644
+
645
+ export async function start(accountId: string, orgId: string) {
646
+ const queued = await syncAccount.enqueue({ accountId, orgId, requestId: crypto.randomUUID() });
647
+ // The run its events are keyed by and the job a cancel names — inside the caller's transaction.
648
+ return { runId: queued.runId, jobId: queued.id };
649
+ }
650
+ ```
651
+
652
+ `deduped` is the one answer only the publish can give: a staged enqueue whose idempotency key a
653
+ LIVE job already holds creates nothing, and its ids then name no row. Give a run its own key
654
+ (a request id) when the caller keeps the ids.
655
+
615
656
  **It is not on by default, and it is not on until you install it** (`As of 2026-08`). Three
616
657
  things have to be true in a process:
617
658
 
@@ -657,8 +698,9 @@ What the lease buys, precisely:
657
698
  | It stops | It does not stop |
658
699
  |---|---|
659
700
  | 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 |
701
+ | 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
702
  | a relay that died mid-batch stranding its rows forever | anything a **non-idempotent** handler does on its second run |
703
+ | — | a lapsed visibility lease re-delivering a job that is still running |
662
704
 
663
705
  The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
664
706
  `retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
@@ -668,12 +710,14 @@ and the loop re-arms: an unobserved rejection would end the process with rows st
668
710
  `relay.stop()` is **async and joins the pass in flight** — `await` it before closing the database,
669
711
  the way `worker.stop()` and `scheduler.stop()` are awaited. A pass is a publish followed by a
670
712
  `markPublished`, and a caller that returned between the two closed the pool under the row it was
671
- about to mark.
713
+ 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
714
 
673
715
  ## Drivers
674
716
 
675
- One interface: `enqueue`, `claim` (visibility timeout), `ack`, `nack` (backoff),
676
- `heartbeat`, `stats`, plus optional `introspect`, `backfills` and `leases`. Zero job-code change
717
+ One interface: `enqueue`, `claim` (visibility timeout), `ack(jobId, { workerId, claim, durationMs? })`,
718
+ `nack(jobId, { workerId, claim, … })` (backoff), `heartbeat`, `stats`, plus optional `introspect`,
719
+ `backfills` and `leases`. Both settles are fenced on the CLAIM — `claimOf(claimed)` is the
720
+ `{ workerId, claim }` a `ClaimedJob` carries — and answer whether they landed. Zero job-code change
677
721
  between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driver` config line**:
678
722
  `JobsConfig.driver` has no reader and boot always builds `createPgDriver`.
679
723
 
@@ -687,6 +731,11 @@ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driv
687
731
  The pg SQL is exported verbatim (`SQL_CLAIM`, `SQL_ENQUEUE`, `SQL_NACK`, …) so an agent
688
732
  debugging a stuck queue can read and run the exact statement.
689
733
 
734
+ A driver you write reads a nack's target state through `nackState(options)` — `dead`
735
+ (`deadLetter`), `failed` (`fail`, terminal and out of the dead-letter queue), `suspended` (`park`)
736
+ or `ready` — so a new branch cannot land in one driver and not another. Its lease store implements
737
+ `holders(key)`: the live holders of one key, in slot order.
738
+
690
739
  ## Roles
691
740
 
692
741
  | Role | Entry | Behaviour |
@@ -732,7 +781,7 @@ import {
732
781
  } from '@ultimat3/jobs';
733
782
 
734
783
  declare const driver: JobDriver;
735
- declare const executor: PgExecutor; // `@ultimat3/cli`'s pgExecutorFor(client)
784
+ declare const executor: PgExecutor; // see "A `PgExecutor`" below
736
785
 
737
786
  createScheduler({
738
787
  driver,
@@ -774,6 +823,95 @@ too, so a scheduler back up after a day down enqueues one catch-up and then wait
774
823
  real occurrence. It used to leave the watermark on the occurrence it had just run, which made an
775
824
  hourly task fire 24 catch-ups a second apart.
776
825
 
826
+ ### A `PgExecutor`
827
+
828
+ `PgExecutor` is one method — `query(text, values)` — and the boot supplies it (`@ultimat3/cli`'s
829
+ `pgExecutorFor(client)`). Outside the boot — a test, a script — build it over `@ultimat3/db`'s
830
+ client, which already speaks `(text, values)` and decodes `jsonb`:
831
+
832
+ ```ts
833
+ import { createPostgresClient } from '@ultimat3/db';
834
+ import type { PgExecutor } from '@ultimat3/jobs';
835
+
836
+ const client = createPostgresClient({ url: 'postgres://localhost:5432/app_test' });
837
+ export const executor: PgExecutor = {
838
+ query: <R>(text: string, values: readonly unknown[]) => client.query<R>({ text, values }),
839
+ };
840
+ ```
841
+
842
+ | Not this | Because |
843
+ |---|---|
844
+ | `{ executor: Bun.sql }` | `Bun.sql` is a tagged template with no `.query(text, values)`: the first claim is a `TypeError` |
845
+ | 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 |
846
+
847
+ ## Idle cost and pickup latency
848
+
849
+ A queue with nothing to do costs almost nothing, and a job starts when it is committed — whichever process committed it.
850
+
851
+ | Loop | While there is work | While idle | Claims per idle minute |
852
+ |---|---|---|---|
853
+ | `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) |
854
+ | outbox relay | a claim every `intervalMs` (200 ms) | the wait doubles to `idlePollMaxMs` | 12 with a proven wake, 30 without (was 300) |
855
+ | `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) |
856
+
857
+ `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.
858
+
859
+ ### The wake
860
+
861
+ `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.
862
+
863
+ ```ts
864
+ import { createPostgresClient } from '@ultimat3/db';
865
+ import type { PgExecutor } from '@ultimat3/jobs';
866
+ import { startQueueWake } from '@ultimat3/jobs';
867
+
868
+ const client = createPostgresClient({ url: 'postgres://localhost:5432/app_test' });
869
+ const executor: PgExecutor = {
870
+ query: <R>(text: string, values: readonly unknown[]) => client.query<R>({ text, values }),
871
+ };
872
+ // `client.listen` is the session; the pooled executor sends the probe that proves it.
873
+ export const wake = startQueueWake({ listener: client, executor });
874
+ ```
875
+
876
+ | Statement | Announces | Stays silent when |
877
+ |---|---|---|
878
+ | `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 |
879
+ | `SQL_OUTBOX_STAGE` | `pg_notify('x_outbox_wake', '')`, delivered on COMMIT | a committed row is already waiting unclaimed |
880
+ | `SQL_SCHEDULER_FIRE` | each queue the occurrence filled, once | the watermark refused the fire |
881
+ | `SQL_JOB_REQUEUE`, `SQL_JOB_PROMOTE`, `requeueMany`, `resumeQueue` | the queue | — |
882
+
883
+ | Rule | Why |
884
+ |---|---|
885
+ | the payload is the queue name, never an id, an input or a tenant | every session on the channel reads it |
886
+ | 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 |
887
+ | 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 |
888
+ | 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 |
889
+ | 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 |
890
+ | 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` |
891
+ | the memory driver needs none | one process: `signalEnqueued` is the whole wake |
892
+
893
+ | A job enqueued by | Starts after — measured, Postgres 17 on loopback, idle worker, `As of 2026-10-01` |
894
+ |---|---|
895
+ | this process — a direct enqueue, the relay's publish, the scheduler's fire | at once (`signalEnqueued`): median 10 ms, max 19 ms |
896
+ | another process, direct | median 8 ms, max 18 ms (was median 1,033 ms, max 1,785 ms: the 2 s poll) |
897
+ | another process, through the outbox — from its COMMIT | median 9 ms, max 13 ms (was median 1,275 ms, max 2,008 ms) |
898
+ | another process, no wake (proxy, lost session) | within `idlePollMaxMs` — 2 s |
899
+ | 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`) |
900
+ | nobody — a delayed job another process queued | within `idlePollMaxMs` past its `runAt` |
901
+
902
+ | Rule | Detail |
903
+ |---|---|
904
+ | 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 |
905
+ | a wake on a pass in flight | asks for ONE more pass behind it, never a second loop |
906
+ | a wake naming a queue the worker does not serve | ignored |
907
+ | 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 |
908
+ | 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 |
909
+ | a standby scheduler | asks for the lease every `renewEveryMs`, not every round |
910
+ | 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 |
911
+ | 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 |
912
+
913
+ Lower `idlePollMaxMs` to trade idle load for the no-wake bound; there is no flag for the old fixed interval.
914
+
777
915
  ## Retries
778
916
 
779
917
  `{ attempts, backoff: 'exponential' | 'linear' | 'fixed', delay, maxDelay, jitter }`.
@@ -833,11 +971,79 @@ cannot hold the cap, so `createWorker().start()` **refuses to boot**
833
971
  Over any cap the claim is handed straight back without burning an attempt — one org's 50k-row
834
972
  import cannot starve the fleet.
835
973
 
974
+ ### One run per key
975
+
976
+ A number caps the whole job. `{ key, limit, whenBusy }` caps it **per key** — "at most one run
977
+ per account, fleet-wide" is a declared field, not three guards in app code:
978
+
979
+ ```ts
980
+ import { job, t } from '@ultimat3/jobs';
981
+
982
+ export const syncAccount = job({
983
+ input: t.object({ accountId: t.uuid, orgId: t.uuid }),
984
+ tenant: ({ orgId }) => orgId,
985
+ idempotencyKey: ({ accountId }) => `sync:${accountId}`,
986
+ retry: { attempts: 3 },
987
+ concurrency: { key: ({ accountId }) => accountId, limit: 1, whenBusy: 'fail' },
988
+ async run({ input, step, finalAttempt }) {
989
+ await step.run('sync', () => ({ accountId: input.accountId, lastTry: finalAttempt }));
990
+ },
991
+ });
992
+ ```
993
+
994
+ | Field | Rule |
995
+ |---|---|
996
+ | `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 |
997
+ | `limit` | max in-flight runs sharing one key. A whole number, 1 or more |
998
+ | `whenBusy: 'wait'` (default) | the run stays `ready`, no attempt burned, claimed again once a slot frees |
999
+ | `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 |
1000
+ | 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 |
1001
+
1002
+ | Refused | Where | Code |
1003
+ |---|---|---|
1004
+ | 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` |
1005
+ | `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` |
1006
+ | a driver with no lease store, for any `concurrency` | `createWorker().start()` | `X_JOB_CONCURRENCY_UNENFORCEABLE` |
1007
+ | a `LeaseStore` with no `holders(key)` | the build — the member is required | `TS2741` |
1008
+
1009
+ What `'fail'` decides, exactly:
1010
+
1011
+ | Case | Answer |
1012
+ |---|---|
1013
+ | another run holds the key | refused — `failed`, `X_JOB_KEY_BUSY`, `JobOutcome` `refused`, `WorkerStats.refused` |
1014
+ | 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 |
1015
+ | the key was busy at the acquire and free a moment later | waits one poll |
1016
+ | the holder's worker was SIGKILLed | the key stays busy until the lease TTL passes, then frees with no cleanup call |
1017
+ | `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 |
1018
+
1019
+ The decision is taken at **every claim**, so a retry or a resumed run claims its key again: under
1020
+ `'fail'`, a different run that took the key in between refuses it.
1021
+
1022
+ The lease row is keyed `job-key:<encoded job name>:<key>` (`jobLeaseKey(name, key)`), apart from
1023
+ the plain cap's `job:<name>` so no job name can collide with another job's key.
1024
+
1025
+ ### `finalAttempt`
1026
+
1027
+ `run({ finalAttempt })` is `true` when a failure of this attempt is not retried for want of
1028
+ attempts — `isFinalAttempt(retry, attempt)`, the one comparison the runner dead-letters on, so a
1029
+ body never re-derives it from `retry.attempts`.
1030
+
1031
+ | Not | Because |
1032
+ |---|---|
1033
+ | "the body runs at most once more" | a `terminal` error stops an earlier attempt |
1034
+ | true exactly once per job | an attempt handed back uncounted — `step.sleep`, a drain — is presented again under the same number |
1035
+
1036
+ `finalAttempt` is a REQUIRED member of `JobRunArgs`: a test that drives a body by hand —
1037
+ `handle.run({ input, step, ctx, attempt, finalAttempt, jobId, runId })` — states it, and
1038
+ `isFinalAttempt(handle.retry, attempt)` is the worker's own answer.
1039
+
836
1040
  ## Leases
837
1041
 
838
1042
  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:
1043
+ `heartbeatIntervalMs` (default a third of the window) for as long as the job runs. Every renewal —
1044
+ the lease, a fleet slot, the worker's registry row — is armed through `schedule`
1045
+ (`IntervalScheduler`, default a real unrefed interval); `@ultimat3/testing`'s `runJobs` hands one
1046
+ driven by the frozen clock. Renewal failures are not swallowed:
841
1047
 
842
1048
  | Fact | Signal |
843
1049
  |---|---|
@@ -856,10 +1062,101 @@ already **on the wire**, not only for the next one: a clean completion acks the
856
1062
  pool was slow enough. The fleet slot's `jobs.worker.slot-lost` had the same shape and the same
857
1063
  fix (`renewal-timer.ts`).
858
1064
 
1065
+ ## The operator surface
1066
+
1067
+ Every operator capability is a member of `JobIntrospection` (`driver.introspect`), implemented by
1068
+ the pg and the memory driver alike. `x jobs`, `/_x` and a dashboard read nothing else.
1069
+
1070
+ | Member | Does | Bound |
1071
+ |---|---|---|
1072
+ | `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` |
1073
+ | `remove(id)` | delete one job and its steps. A running one is `X_JOB_NOT_REMOVABLE` | — |
1074
+ | `requeueMany({ state, queue?, name?, tenantId? })` | re-queue finished rows; a row whose key a live job holds stays | `MAX_BULK_ROWS` = 1,000 per call; answers `{ affected, remaining }` |
1075
+ | `removeMany({ state, queue?, name?, tenantId? })` | delete rows in any state but `running` | the same |
1076
+ | `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 |
1077
+ | `promote(id)` | a job waiting on its `runAt` — delayed, or backing off — is due now | — |
1078
+ | `pauseQueue(q)` / `resumeQueue(q)` / `pausedQueues()` | a paused queue is claimed by no worker; enqueues still land | effective at every worker's next claim |
1079
+ | `pauseTask(t)` / `resumeTask(t)` / `pausedTasks()` | a paused task is not dispatched; on resume its own `catchUp` decides what it missed | — |
1080
+ | `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 |
1081
+ | `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 |
1082
+ | `rollupCounters()` | fold buckets past their tier into the next; the scheduler leader calls it once a minute | idempotent |
1083
+ | `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 |
1084
+ | `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 |
1085
+ | `recordTaskFire({ task, occurrenceMs })` | the scheduler's record of a fire that went through `driver.enqueue`; the pg fire writes it in `SQL_SCHEDULER_FIRE` | — |
1086
+
1087
+ | Rule | Why |
1088
+ |---|---|
1089
+ | 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 |
1090
+ | a shed, a suspension and a drained attempt add nothing | they are handed back uncounted; counted, every `step.sleep` would read as a failure |
1091
+ | `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` |
1092
+ | `ack(id, { …, counted: false })` adds nothing to the counters | `x jobs drain` settles the row it moved: `done` here, and not a completed run |
1093
+ | 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 |
1094
+ | an occurrence fires in one statement | the watermark and its jobs move together (`SchedulerState.fire`), so a crash between them cannot fire it twice |
1095
+
1096
+ `run({ progress })` reports how far a run has got — `progress(done, total, note?)`, as often as
1097
+ the loop turns.
1098
+
1099
+ ### `onSettled` — how a run ended
1100
+
1101
+ ONE hook, told once per ending. There is no `onDead` and no `onComplete` beside it.
1102
+
1103
+ ```ts
1104
+ import { job, t } from '@ultimat3/jobs';
1105
+
1106
+ /** The app's own reactions — tenant-scoped writes, which is why the hook runs under the tenant. */
1107
+ const markSynced = (_accountId: string, _rows: number): Promise<void> => Promise.resolve();
1108
+ const markBroken = (_accountId: string, _code: string | undefined): Promise<void> =>
1109
+ Promise.resolve();
1110
+
1111
+ export const syncAccountWatched = job({
1112
+ input: t.object({ accountId: t.uuid, orgId: t.uuid }),
1113
+ tenant: ({ orgId }) => orgId,
1114
+ idempotencyKey: ({ accountId }) => `sync:${accountId}`,
1115
+ retry: { attempts: 3 },
1116
+ concurrency: { key: ({ accountId }) => accountId, limit: 1, whenBusy: 'fail' },
1117
+ async run({ input, progress }) {
1118
+ progress(0, 2, `account ${input.accountId}`);
1119
+ progress(2, 2);
1120
+ return { rows: 2 };
1121
+ },
1122
+ async onSettled(settled) {
1123
+ if (settled.outcome === 'completed') {
1124
+ await markSynced(settled.input.accountId, settled.result.rows);
1125
+ } else if (settled.input !== undefined) {
1126
+ await markBroken(settled.input.accountId, settled.code);
1127
+ }
1128
+ },
1129
+ });
1130
+ ```
1131
+
1132
+ | The run ended | `outcome` | Carries | Row state when the hook runs |
1133
+ |---|---|---|---|
1134
+ | the body returned and the ack landed | `completed` | `input`, `result` — what `run` returned, typed from it | `done` |
1135
+ | failed for good, parked in the dead-letter queue | `dead-lettered` | `input` (or `undefined`: the stored payload no longer parses), `error`, `code` | `dead` |
1136
+ | failed for good on `retry.deadLetter: false` | `dropped` | the same | `failed` |
1137
+ | `whenBusy: 'fail'` over a busy key — the body never ran | `refused` | the same, `code: 'X_JOB_KEY_BUSY'` | `failed` |
1138
+
1139
+ | Not a settlement | Who observes it instead |
1140
+ |---|---|
1141
+ | a retry, a `step.sleep` / `waitForEvent`, a drain | nobody: the run has not ended |
1142
+ | `cancelJob()` / `x jobs cancel` on a queued or running row | the caller of the cancel — the worker's own settle then matches nothing |
1143
+ | `x jobs retry`, `rm`, `promote`, `drain` | the operator; a retried run settles again, and is told again |
1144
+ | a settle that did not land — the lease lapsed and the queue re-delivered | the claim that holds the row now |
1145
+
1146
+ | Guarantee | Stated plainly |
1147
+ |---|---|
1148
+ | when | AFTER the row is settled, by the worker whose settle landed, under the job's declared tenant; `settled.ctx` is that context |
1149
+ | 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 |
1150
+ | 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 |
1151
+ | 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 |
1152
+ | `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 |
1153
+ | 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 |
1154
+
859
1155
  ## Introspection
860
1156
 
861
- `inspectQueues`, `inspectJob` (per-step trace), `inspectDeadLetters`, `retryFromStep`,
862
- `cancelJob`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
1157
+ `inspectQueues`, `inspectJob` (per-step trace, the redacted `input`, the failure's `stack`, the
1158
+ last `progress`), `inspectDeadLetters`, `retryFromStep`, `cancelJob`, `removeJob`, `promoteJob`,
1159
+ `pauseQueue`, `resumeQueue`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
863
1160
 
864
1161
  `cancelJob(driver, id, reason?)` is the answer to a runaway pass. A queued row becomes `cancelled`
865
1162
  immediately; a RUNNING one stops at its next heartbeat, which no longer matches its own row and
@@ -902,7 +1199,13 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
902
1199
  | `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
1200
  | `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
1201
  | `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 |
1202
+ | `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency`, plain or keyed, and the driver has no lease store |
1203
+ | `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` |
1204
+ | `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 |
1205
+ | `X_JOB_NOT_REMOVABLE` | `remove` / `removeMany` reached a running job. Fix: `x jobs cancel <id> --json` |
1206
+ | `X_JOB_NOT_PROMOTABLE` | `promote` reached a job not waiting on its run time |
1207
+ | `X_JOB_PAGE_INVALID` | `list()` was asked for more than `MAX_JOB_PAGE` rows, or handed a cursor no page produced. Fix: `list({ limit: 200, after: jobCursor(lastRow) })` |
1208
+ | `X_JOB_ON_SETTLED_FAILED` | a declared `onSettled` threw on every one of its tries; logged and reported, never thrown |
906
1209
  | `X_NOT_IMPLEMENTED` | redis / nats driver |
907
1210
 
908
1211
  ### Error classes
@@ -922,19 +1225,26 @@ a job boundary the class is gone and the `code` is what survives — match on th
922
1225
  | `BackfillUnknownError` | `X_BACKFILL_UNKNOWN` | `src/backfill-errors.ts` |
923
1226
  | `CancelUnsupportedError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
924
1227
  | `ClaimQueuesEmptyError` | `X_JOB_CLAIM_QUEUES_EMPTY` | `src/errors.ts` |
925
- | `ConcurrencyUnenforceableError` | `X_JOB_CONCURRENCY_UNENFORCEABLE` | `src/errors.ts` |
1228
+ | `ConcurrencyUnenforceableError` | `X_JOB_CONCURRENCY_UNENFORCEABLE` | `src/errors-concurrency.ts` |
926
1229
  | `DriverUnavailableError` | `X_DRIVER_UNAVAILABLE` | `src/errors.ts` |
927
1230
  | `ExportPartTooLargeError` | `X_EXPORT_PART_TOO_LARGE` | `src/export-errors.ts` |
928
1231
  | `ExportRowInvalidError` | `X_EXPORT_ROW_INVALID` | `src/export-errors.ts` |
929
1232
  | `IdempotencyRequiredError` | `X_IDEMPOTENCY_REQUIRED` | `src/errors.ts` |
930
1233
  | `JobAbortedError` | `X_ABORTED` | `src/errors.ts` |
1234
+ | `JobConcurrencyInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-concurrency.ts` |
1235
+ | `JobConcurrencyKeyInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-concurrency.ts` |
931
1236
  | `JobDeclarationInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-declaration.ts` |
932
1237
  | `JobDrainedError` | `X_DRAINING` | `src/errors.ts` |
933
1238
  | `JobDuplicateError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
1239
+ | `JobKeyBusyError` | `X_JOB_KEY_BUSY` | `src/errors-concurrency.ts` |
934
1240
  | `JobMaxAttemptsError` | `X_JOB_MAX_ATTEMPTS` | `src/errors.ts` |
935
1241
  | `JobNameTakenError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
936
1242
  | `JobNotCancellableError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
1243
+ | `JobNotPromotableError` | `X_JOB_NOT_PROMOTABLE` | `src/errors-operator.ts` |
1244
+ | `JobNotRemovableError` | `X_JOB_NOT_REMOVABLE` | `src/errors-operator.ts` |
937
1245
  | `JobNotRequeueableError` | `X_JOB_NOT_REQUEUEABLE` | `src/errors-requeue.ts` |
1246
+ | `JobOnSettledFailedError` | `X_JOB_ON_SETTLED_FAILED` | `src/errors-operator.ts` |
1247
+ | `JobPageInvalidError` | `X_JOB_PAGE_INVALID` | `src/errors-operator.ts` |
938
1248
  | `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
939
1249
  | `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
940
1250
  | `JobsNotImplementedError` | `X_NOT_IMPLEMENTED` | `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": "23.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": "23.0.0",
36
+ "@ultimat3/db": "23.0.0",
37
+ "@ultimat3/entity": "23.0.0",
38
+ "@ultimat3/schema": "23.0.0",
39
+ "@ultimat3/time": "23.0.0"
40
40
  }
41
41
  }
@@ -0,0 +1,144 @@
1
+ // `job.concurrency` as DECLARED, resolved once: a plain number is one cap for the whole job, and
2
+ // `{ key, limit, whenBusy }` is that cap per key — "at most N runs of this job for this account,
3
+ // fleet-wide". Apart from `job.ts` because that file is the primitive and its registry; this one
4
+ // is the single reader of the two spellings, so the worker and the projections never branch on
5
+ // which one an author wrote.
6
+
7
+ import type { ConcurrencyKeyDefect } from './errors-concurrency';
8
+ import { JobConcurrencyInvalidError, JobConcurrencyKeyInvalidError } from './errors-concurrency';
9
+
10
+ /** What a claim does when its key already holds `limit` runs. Closed: there is no third answer. */
11
+ export const WHEN_BUSY = ['wait', 'fail'] as const;
12
+
13
+ export type WhenBusy = (typeof WHEN_BUSY)[number];
14
+
15
+ export interface KeyedConcurrency<I> {
16
+ /**
17
+ * The lease this input's run counts under. Deterministic from `input` only — it is asked once
18
+ * at enqueue and again at every claim, and two answers for one input are two locks.
19
+ */
20
+ readonly key: (input: I) => string;
21
+ /** Max in-flight runs sharing one key, across the fleet. A whole number, 1 or more. */
22
+ readonly limit: number;
23
+ /**
24
+ * `'wait'` (default): the run stays queued and is claimed again once a slot frees.
25
+ * `'fail'`: the run settles `failed` with `X_JOB_KEY_BUSY`, its body never runs, nothing retries it.
26
+ *
27
+ * There is no `duration`: the bound after which a stuck holder stops blocking its key is the
28
+ * lease TTL the heartbeat renews (`leases.ts`), and a second number that could disagree with it
29
+ * would be a second way to say one thing.
30
+ */
31
+ readonly whenBusy?: WhenBusy;
32
+ }
33
+
34
+ export type JobConcurrency<I> = number | KeyedConcurrency<I>;
35
+
36
+ /**
37
+ * A lease row is keyed by this text and `x_job_leases` indexes it, so a key is an id, never a
38
+ * payload. Far under what a btree entry holds, and far over any account or connection id.
39
+ */
40
+ export const MAX_CONCURRENCY_KEY_LENGTH = 200;
41
+
42
+ export interface ResolvedConcurrency<I> {
43
+ /** The cap — per key when `whenBusy` is set, for the whole job when it is not. */
44
+ readonly limit: number | undefined;
45
+ /** Set exactly when the cap is KEYED, so it doubles as that question's answer. */
46
+ readonly whenBusy: WhenBusy | undefined;
47
+ /**
48
+ * The key this input counts under, or `undefined` when the cap is not keyed. `name` is the
49
+ * job's CURRENT name — registration renames a handle in place, after this was resolved.
50
+ */
51
+ keyFor(name: string, input: I): string | undefined;
52
+ }
53
+
54
+ const unkeyed = (): undefined => undefined;
55
+
56
+ /** A cap some worker can fill: whole, and at least one. Refuses `NaN`, `Infinity` and `1.5` too. */
57
+ const fillable = (limit: unknown): limit is number =>
58
+ typeof limit === 'number' && Number.isInteger(limit) && limit >= 1;
59
+
60
+ const isWhenBusy = (value: unknown): value is WhenBusy =>
61
+ (WHEN_BUSY as readonly unknown[]).includes(value);
62
+
63
+ /**
64
+ * Refuses a declaration no worker can honour, where it is written. A `limit` of zero is a slot
65
+ * table that grants nothing — `leases.acquire(key, 0, …)` answers `undefined` forever with no log
66
+ * line — and `NaN` / `Infinity` / `1.5` are the same defect spelled three other ways.
67
+ */
68
+ export function resolveConcurrency<I>(
69
+ job: string,
70
+ declared: JobConcurrency<I> | undefined,
71
+ ): ResolvedConcurrency<I> {
72
+ if (declared === undefined) return { limit: undefined, whenBusy: undefined, keyFor: unkeyed };
73
+ if (typeof declared === 'number') {
74
+ // ONE refusal for both spellings: the plain number used to raise `X_INVARIANT`, which was a
75
+ // second code for the statement `concurrency.limit` makes below.
76
+ if (!fillable(declared)) {
77
+ throw new JobConcurrencyInvalidError({
78
+ job,
79
+ field: 'concurrency',
80
+ declared,
81
+ why: 'which no worker can ever fill',
82
+ edit: 'set a whole concurrency of 1 or more, or omit the field for no cap at all,',
83
+ });
84
+ }
85
+ return { limit: declared, whenBusy: undefined, keyFor: unkeyed };
86
+ }
87
+ // Read off the VALUE: generated code and JS callers reach here with what the type forbids.
88
+ const loose: unknown = declared;
89
+ if (typeof loose !== 'object' || loose === null) {
90
+ throw new JobConcurrencyInvalidError({
91
+ job,
92
+ field: 'concurrency',
93
+ declared: loose,
94
+ why: 'which is neither a whole number nor { key, limit }',
95
+ edit: 'set concurrency: { key: (input) => input.accountId, limit: 1 }',
96
+ });
97
+ }
98
+ const { key, limit, whenBusy } = declared;
99
+ if (typeof key !== 'function') {
100
+ throw new JobConcurrencyInvalidError({
101
+ job,
102
+ field: 'concurrency.key',
103
+ declared: key,
104
+ why: 'which is not a function of the input',
105
+ edit: 'set concurrency.key: (input) => input.accountId',
106
+ });
107
+ }
108
+ if (!fillable(limit)) {
109
+ throw new JobConcurrencyInvalidError({
110
+ job,
111
+ field: 'concurrency.limit',
112
+ declared: limit,
113
+ why: 'which no worker can ever fill',
114
+ edit: 'set a whole concurrency.limit of 1 or more',
115
+ });
116
+ }
117
+ if (whenBusy !== undefined && !isWhenBusy(whenBusy)) {
118
+ throw new JobConcurrencyInvalidError({
119
+ job,
120
+ field: 'concurrency.whenBusy',
121
+ declared: whenBusy,
122
+ why: `and only ${WHEN_BUSY.join(' and ')} exist`,
123
+ edit: "set concurrency.whenBusy to 'wait' (the run stays queued) or 'fail' (the run settles failed with X_JOB_KEY_BUSY)",
124
+ });
125
+ }
126
+
127
+ return {
128
+ limit,
129
+ whenBusy: whenBusy ?? 'wait',
130
+ keyFor(name: string, input: I): string {
131
+ const answered: unknown = key(input);
132
+ const refuse = (defect: ConcurrencyKeyDefect): JobConcurrencyKeyInvalidError =>
133
+ new JobConcurrencyKeyInvalidError({
134
+ job: name,
135
+ defect,
136
+ maxLength: MAX_CONCURRENCY_KEY_LENGTH,
137
+ });
138
+ if (typeof answered !== 'string') throw refuse('unreadable');
139
+ if (answered.length === 0) throw refuse('empty');
140
+ if (answered.length > MAX_CONCURRENCY_KEY_LENGTH) throw refuse('too-long');
141
+ return answered;
142
+ },
143
+ };
144
+ }