@ultimat3/jobs 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -35,16 +35,35 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
35
35
  A look-alike never registers. Deliberately not a registry lookup — the registry is what
36
36
  registration rewrites.
37
37
  - `idempotencyKey` is NON-OPTIONAL in `JobDefinition`. Never relax it, never default it.
38
- - **The idempotency namespace is `(name, idempotency_key)`, never the key alone** (`As of
39
- 2026-08`). It was the key alone, and that is silent data loss with no error anywhere: two jobs
40
- that derive the same natural key from the same input — `sendWelcomeEmail` and
41
- `provisionWorkspace` both keyed `user:${id}` — shared one namespace, so the second enqueue hit
42
- `on conflict do nothing`, fell through to `SQL_FIND_LIVE_BY_KEY`, found the FIRST job's row and
43
- returned `{ id: <A's>, deduped: true }`. The workspace was never provisioned and `x jobs ls`
44
- showed one healthy job. Three places have to agree and a test pins them together: the index, the
45
- conflict target, and the live-row lookup. `x_jobs` SHIPPED, so the DDL `drop index if exists`es
46
- the old one left in place it would keep enforcing exactly the collision this fixes. The
47
- scheduler's occurrence key already prefixes the task name and is unaffected.
38
+ - **The idempotency namespace is `(name, coalesce(tenant_id, ''), idempotency_key)`, never the key
39
+ alone and never name-only** (`As of 2026-08`). Two rounds of the same defect, and both are silent
40
+ data loss with no error anywhere.
41
+
42
+ It was the key alone: two jobs deriving the same natural key from the same input
43
+ `sendWelcomeEmail` and `provisionWorkspace` both keyed `user:${id}` shared one namespace, so
44
+ the second enqueue hit `on conflict do nothing`, fell through to `SQL_FIND_LIVE_BY_KEY`, found
45
+ the FIRST job's row and returned `{ id: <A's>, deduped: true }`. The workspace was never
46
+ provisioned and `x jobs ls` showed one healthy job.
47
+
48
+ Then it was name-only, while the row already carried `tenant_id` as `$9` of the same insert.
49
+ Every natural key the docs suggest is unique only WITHIN a tenant — `` `invoice:${input.invoiceId}` ``,
50
+ `` `order:${input.orderNumber}` `` — so tenant B enqueuing while tenant A held that key deduped
51
+ into tenant A's row: B's work never ran AND B's caller received A's job id, which is valid on
52
+ every id-addressed surface (`cancelJob(driver, jobId)` takes an id with no tenant predicate, so
53
+ an app wiring the returned id to a cancel button gave B cancellation of A's job). The sibling
54
+ projection in `@ultimat3/action` (`idempotency-key.ts`) had folded the actor's org in all along.
55
+ `coalesce`, not the bare column: a null `tenant_id` compares unequal to every other null under a
56
+ unique index, so a tenantless queue would lose its dedupe entirely — all tenantless rows share
57
+ one namespace instead, which is what they had before tenancy existed.
58
+
59
+ Three places have to agree and a test pins them together: the index, the conflict target, and the
60
+ live-row lookup. The conflict target must spell the index EXPRESSION exactly —
61
+ `(name, (coalesce(tenant_id, '')), idempotency_key)` — or Postgres cannot infer the index at all.
62
+ `driver-memory.ts` mirrors it with `(record.tenantId ?? '')`, which is the parity that turned a
63
+ gap into a confirmed one rather than catching it. `x_jobs` SHIPPED, so the DDL
64
+ `drop index if exists`es BOTH superseded indexes — each is strictly narrower than its successor,
65
+ so either left in place would keep enforcing exactly the collision this fixes. The scheduler's
66
+ occurrence key already prefixes the task name and is unaffected.
48
67
  - **`SQL_JOBS_TABLE` is the ONE install point, and every durable table this package owns is in
49
68
  it** (`As of 2026-08`): `x_jobs`, `x_job_steps`, `x_backfills`, `x_outbox`, `x_scheduler_state`,
50
69
  `x_scheduler_leader`, `x_job_leases`, `x_job_events`. Four of those were subsystems that shipped
@@ -457,8 +476,14 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
457
476
  passes it to both calls. `markPublished` also gained `published_at is null`, so the stamp is
458
477
  first-writer-wins rather than a rewrite of an audit timestamp. The memory store fences the same
459
478
  way — per CLAIM there rather than per relay, because two relays there are two `claim()` calls on
460
- ONE store, and a per-store id could not tell them apart. `undefined` is unfenced in both, for the
461
- reason `release` is optional: a caller holding no token is one written before the fence.
479
+ ONE store, and a per-store id could not tell them apart. **An absent token is NOT one rule in
480
+ both**: `createMemoryOutboxStore`'s `owns(id, undefined)` answers `true` unconditionally, so a
481
+ caller with no token really is unfenced there — while `createPgOutboxStore` substitutes
482
+ `claimant ?? relayId` into `SQL_OUTBOX_RELEASE` and `SQL_OUTBOX_MARK_PUBLISHED`, both of which
483
+ carry `and claimed_by = $n`, so a token-less call fences on THIS store's relay id and no-ops
484
+ against a row some other relay holds. `outbox-pg.ts:159-165` is the honest comment. Neither store
485
+ refuses such a caller, which is the shared half: a caller holding no token is one written before
486
+ the fence.
462
487
  **`order by staged_at` was not a total order**: every row staged in one transaction shares a
463
488
  `staged_at`, so the tie was the planner's to break — which rows the `limit` takes, and in which
464
489
  order they publish, differed between two relays and between two runs of one. `, id` fixes it in
@@ -528,8 +553,20 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
528
553
  `backfill-pass-fixture.ts` raises `BackfillHandleFailure`, a plain `Error` subclass on purpose:
529
554
  a backfill `handle` is app code and the pass propagates what it threw, so a framework code there
530
555
  would exercise a path no app takes.
531
- - Suspension is control flow: `StepSuspension` -> `nack({ countsAsAttempt: false })`.
532
- Never log it as an error, never let it burn an attempt.
556
+ - **Suspension is control flow, and a SHED is not a suspension** (`As of 2026-08`).
557
+ `StepSuspension` -> `nack({ countsAsAttempt: false, park: true })`; never log it as an error,
558
+ never let it burn an attempt. The two facts were ONE flag until 2026-08: a limiter shed and a
559
+ `job.concurrency` shed both handed the job back with `countsAsAttempt: false`, and both drivers
560
+ derived `deadLetter ? 'dead' : counts ? 'ready' : 'suspended'` — so a job that is merely WAITING
561
+ was filed beside a 3-day sleep. `SQL_STATS` and the memory `stats()` then counted it out of
562
+ `ready` and out of `oldest_ready_ms`, which `worker.ts` publishes as `queue_depth` and
563
+ `queue_oldest_ready_seconds`: 20 jobs at `concurrency: 10` behind `createLimiter({ global: 1 })`
564
+ read as a depth of 10 with 19 waiting, and under sustained overload the shed fraction approaches
565
+ 100%, so the HPA signal and the "oldest job older than 5 minutes" page both go quiet exactly
566
+ when the queue is saturated. `park` is now the state and `countsAsAttempt` is the counter, only.
567
+ The shed also wrote `last_error = 'limited: …'`, so `x jobs show` reported a failure for a job
568
+ that never ran — it is a `jobs.worker.shed` log field now, and `worker.ts`'s one `shed()` is
569
+ where both sheds go. `driver-parity.test.ts` pins which bucket each lands in, in both drivers.
533
570
  - Step results are persisted BEFORE the step returns. Keep it that way or replay breaks.
534
571
  - All time is epoch ms from an injected `Clock`, read via `nowMs()` in `clock.ts`.
535
572
  - Drivers implement exactly the six `JobDriver` methods plus optional `introspect`, `backfills`
@@ -604,7 +641,8 @@ picture from the other side.
604
641
  | `backfill-pending.ts` | declared minus completed, per environment: the alarm `--pending` reads |
605
642
  | `backfill-rate.ts` | the `rate` throttle: batches/sec as an interval, and the cancellable wait |
606
643
  | `backfill-inspect.ts` | the ledger projected for `x db backfill`, `x jobs`, `/_x` and MCP |
607
- | `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements |
644
+ | `backfill-errors.ts` | the seven `X_BACKFILL_*` classes — split out of `errors.ts`, which was over the 500-line ceiling. The codes themselves stay declared in `errors.ts`: one registry, one place |
645
+ | `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements. Skips a non-job in silence — a module namespace is full of helpers — EXCEPT an `@ultimat3/action` projection (`kind: 'action-job'`), which is `X_ACTION_JOB_UNBRIDGED` |
608
646
  | `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
609
647
  | `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
610
648
  | `outbox.ts` | staging in a `Tx`, the relay, the ambient `JobsFacade` slot |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,9 +32,9 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "3.0.0",
36
- "@ultimat3/entity": "3.0.0",
37
- "@ultimat3/schema": "3.0.0",
38
- "@ultimat3/time": "3.0.0"
35
+ "@ultimat3/core": "4.0.0",
36
+ "@ultimat3/entity": "4.0.0",
37
+ "@ultimat3/schema": "4.0.0",
38
+ "@ultimat3/time": "4.0.0"
39
39
  }
40
40
  }
@@ -0,0 +1,137 @@
1
+ // The seven `X_BACKFILL_*` codes, apart from `errors.ts` for the reason `driver-pg-rows.ts` is
2
+ // apart from `driver-pg.ts`: one file, one job, and `errors.ts` was over the 500-line ceiling
3
+ // `x verify`'s `filesize` step enforces. The registry stays there — `JOB_OWNED_ERROR_CODES`,
4
+ // `JOB_ERROR_TITLES` and the single `registerErrorCodes()` call — because a package's codes are
5
+ // declared in ONE place; only the classes that throw them live here, beside `backfill-ledger.ts`,
6
+ // `backfill-pending.ts` and `backfill-registry.ts`.
7
+
8
+ import { UltimateError } from '@ultimat3/core';
9
+ import { docsFor } from './errors';
10
+
11
+ /**
12
+ * The seven backfill codes below all answer one question — "why is this sweep not running?" — and
13
+ * each is here because it sends the reader somewhere different: run it, force it, change
14
+ * environment, migrate first, wait, fix the predicates, or fix the name. A code that shared a fix
15
+ * line with another one would be code inflation, which is why `X_BACKFILL_WRITE_UNCONFIRMED` was
16
+ * considered and rejected: a dry run that wrote nothing did exactly what it was asked to.
17
+ *
18
+ * Every `fix` below is ONE line something can execute — a shell command, or the edit to make.
19
+ * Never a command with prose appended: a `fix:` is copied and run verbatim, so a trailing clause
20
+ * turns a working command into a syntax error at the one moment the reader is following it
21
+ * literally. Explanations belong in `cause`, which is read and never run.
22
+ */
23
+
24
+ /**
25
+ * Declared and never completed. The alarm the framework did not have: an author could
26
+ * `x g backfill`, merge and deploy, and nothing anywhere said the pass had not run.
27
+ */
28
+ export class BackfillPendingError extends UltimateError {
29
+ constructor(input: { backfill: string; environment: string }) {
30
+ super({
31
+ code: 'X_BACKFILL_PENDING',
32
+ cause: `backfill "${input.backfill}" is declared and x_backfills holds no completed pass for it in ${input.environment}`,
33
+ fix: `x db backfill ${input.backfill} --write --json`,
34
+ docs: docsFor('X_BACKFILL_PENDING'),
35
+ });
36
+ }
37
+ }
38
+
39
+ /** Already swept. A rerun is legitimate, so this names the flag rather than refusing outright. */
40
+ export class BackfillAppliedError extends UltimateError {
41
+ constructor(input: { backfill: string; runId: string; completedAt: string }) {
42
+ super({
43
+ code: 'X_BACKFILL_APPLIED',
44
+ cause: `backfill "${input.backfill}" completed as run ${input.runId} at ${input.completedAt}; a forced rerun writes a NEW ledger row and never edits that one`,
45
+ fix: `x db backfill ${input.backfill} --write --force --json`,
46
+ docs: docsFor('X_BACKFILL_APPLIED'),
47
+ });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The declaration names the environments it belongs to and this is not one. Declared DATA, never a
53
+ * hardcoded "cleanups are production": a staging rehearsal is correct practice, so which
54
+ * environments a sweep belongs to is the app's convention and this is only the mechanism carrying
55
+ * it (axiom 8).
56
+ */
57
+ export class BackfillEnvironmentError extends UltimateError {
58
+ constructor(input: { backfill: string; environment: string; declared: readonly string[] }) {
59
+ // The first declared environment, because the fix has to be ONE runnable line and the list is
60
+ // ordered by the author. The empty case cannot arise from `checkBackfillEnvironment`, which
61
+ // treats an empty list as "every environment" — but this constructor is public, so it answers
62
+ // with the command that lists what IS declared rather than an `ULTIMATE_ENV=undefined`.
63
+ const target = input.declared[0];
64
+ super({
65
+ code: 'X_BACKFILL_ENVIRONMENT',
66
+ cause: `backfill "${input.backfill}" declares environments: ${input.declared.join(', ')} and this process resolved ${input.environment} — add "${input.environment}" to that list if this deploy should sweep too`,
67
+ fix:
68
+ target === undefined
69
+ ? 'x db backfill --pending --json'
70
+ : `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
71
+ docs: docsFor('X_BACKFILL_ENVIRONMENT'),
72
+ });
73
+ }
74
+ }
75
+
76
+ /**
77
+ * `requires` names a migration the ledger has not applied. Checked where `x_migrations` is
78
+ * readable — this package holds no `@ultimat3/db` dependency and growing one to read a ledger
79
+ * would put the migration engine on the tier-3 queue's import graph.
80
+ */
81
+ export class BackfillMigrationPendingError extends UltimateError {
82
+ constructor(input: { backfill: string; migration: string }) {
83
+ super({
84
+ code: 'X_BACKFILL_MIGRATION_PENDING',
85
+ cause: `backfill "${input.backfill}" requires migration ${input.migration}, which x_migrations does not record as applied`,
86
+ fix: 'x db migrate --json',
87
+ docs: docsFor('X_BACKFILL_MIGRATION_PENDING'),
88
+ });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The enqueue deduped: one live pass per name, so this run is the one already going. Distinct from
94
+ * `X_BACKFILL_APPLIED`, which is a pass that finished — the response there is `--force`, and the
95
+ * response here is to look at the run that is holding the key.
96
+ */
97
+ export class BackfillRunningError extends UltimateError {
98
+ constructor(input: { backfill: string; jobId: string }) {
99
+ super({
100
+ code: 'X_BACKFILL_RUNNING',
101
+ cause: `backfill "${input.backfill}" already has a live pass queued as ${input.jobId}, and one name holds one live pass; its step trace names the batch it is on, and a pass that is not advancing is a worker that lost its lease`,
102
+ fix: `x jobs show ${input.jobId} --json`,
103
+ docs: docsFor('X_BACKFILL_RUNNING'),
104
+ });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The source ran out of rows and the declaration's own `count()` still matches some. Two
110
+ * predicates that disagree is an authoring bug in any business — the sweep reported success over
111
+ * rows it never visited — so the pass fails rather than writing a completed row nobody can trust.
112
+ */
113
+ export class BackfillStalledError extends UltimateError {
114
+ constructor(input: { backfill: string; remaining: number; swept: number }) {
115
+ super({
116
+ code: 'X_BACKFILL_STALLED',
117
+ cause: `backfill "${input.backfill}" swept ${input.swept} rows, exhausted its source, and count() still matches ${input.remaining} — a WHERE the sweep narrows and the count does not is what leaves rows behind`,
118
+ fix: `make count() select on exactly what source() selects on in backfill("${input.backfill}")`,
119
+ docs: docsFor('X_BACKFILL_STALLED'),
120
+ });
121
+ }
122
+ }
123
+
124
+ /** A name no declaration in this app carries — a typo, or a backfill whose module was deleted. */
125
+ export class BackfillUnknownError extends UltimateError {
126
+ constructor(input: { backfill: string; known: readonly string[] }) {
127
+ super({
128
+ code: 'X_BACKFILL_UNKNOWN',
129
+ cause:
130
+ input.known.length === 0
131
+ ? `no backfill named "${input.backfill}" is declared, and this app declares none at all`
132
+ : `no backfill named "${input.backfill}" is declared (declared: ${input.known.join(', ')})`,
133
+ fix: 'x db backfill --pending --json',
134
+ docs: docsFor('X_BACKFILL_UNKNOWN'),
135
+ });
136
+ }
137
+ }
@@ -8,15 +8,15 @@
8
8
  // convention rather than a rail (axiom 3).
9
9
 
10
10
  import type { Environment, UltimateError } from '@ultimat3/core';
11
- // `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
12
- // own row shape, which would make this a second reader of `x_backfills`.
13
- import type { BackfillProgress } from './backfill-inspect';
14
- import type { BackfillDeclaration } from './backfill-registry';
15
11
  import {
16
12
  BackfillAppliedError,
17
13
  BackfillEnvironmentError,
18
14
  BackfillMigrationPendingError,
19
- } from './errors';
15
+ } from './backfill-errors';
16
+ // `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
17
+ // own row shape, which would make this a second reader of `x_backfills`.
18
+ import type { BackfillProgress } from './backfill-inspect';
19
+ import type { BackfillDeclaration } from './backfill-registry';
20
20
 
21
21
  export type BackfillGate =
22
22
  | { readonly run: true }
@@ -16,13 +16,13 @@
16
16
  import { appVersion, assert, logger, resolveEnvironment } from '@ultimat3/core';
17
17
  import type { BatchIterator } from '@ultimat3/entity';
18
18
  import type { BackfillDefinition, BackfillInput, BackfillReport } from './backfill';
19
+ import { BackfillStalledError } from './backfill-errors';
19
20
  import { checkBackfillEnvironment } from './backfill-gate';
20
21
  import type { BackfillLedger, BackfillRun } from './backfill-ledger';
21
22
  import { decideBackfill } from './backfill-ledger';
22
23
  import type { Pacer } from './backfill-rate';
23
24
  import { withBackfillScope } from './backfill-scope';
24
25
  import { jobDriver } from './driver';
25
- import { BackfillStalledError } from './errors';
26
26
  import type { JobRunArgs } from './job';
27
27
  import { isStepSuspension } from './steps';
28
28
 
package/src/describe.ts CHANGED
@@ -14,10 +14,18 @@ export interface JobDescriptor {
14
14
  readonly queue: string;
15
15
  readonly retry: { readonly attempts: number; readonly backoff: BackoffStrategy };
16
16
  readonly steps: readonly string[];
17
+ /**
18
+ * Whether a replayed attempt is safe to run — `job()` REQUIRES an `idempotencyKey` and refuses
19
+ * a definition without one (`X_IDEMPOTENCY_REQUIRED`), so this is `true` for every registered
20
+ * job. That is the point: the guarantee, published where an operator asks the question, rather
21
+ * than left as prose in a doc. The KEY itself never crosses — it is computed from an input and
22
+ * is app data, so a descriptor carrying it would put customer ids in `x.manifest.json`.
23
+ */
24
+ readonly idempotent: boolean;
17
25
  }
18
26
 
19
27
  /**
20
- * Narrower than `JobHandle` on purpose: the projection reads four declared fields, so keeping
28
+ * Narrower than `JobHandle` on purpose: the projection reads five declared fields, so keeping
21
29
  * it structural means it never has to carry — or vary with — the handle's input generic.
22
30
  */
23
31
  export interface DescribableJob {
@@ -25,6 +33,13 @@ export interface DescribableJob {
25
33
  readonly queue: string;
26
34
  readonly retry: RetryPolicy;
27
35
  readonly input: unknown;
36
+ /**
37
+ * `JobHandle.idempotencyKeyFor`, read only for its presence — `unknown` because the real
38
+ * signature is `(input: I) => string` and this shape is deliberately free of the generic.
39
+ * Required, not optional: a descriptor built without it would publish `idempotent: false`,
40
+ * which is the exact wrong answer the `/_x` jobs panel used to give for every job.
41
+ */
42
+ readonly idempotencyKeyFor: unknown;
28
43
  }
29
44
 
30
45
  export function describeJob(handle: DescribableJob): JobDescriptor {
@@ -39,6 +54,7 @@ export function describeJob(handle: DescribableJob): JobDescriptor {
39
54
  // Empty by design: step names are chosen inside `run()` at execution time, so they are
40
55
  // not statically knowable. `inspect(name)` reports the steps an actual run recorded.
41
56
  steps: [],
57
+ idempotent: typeof handle.idempotencyKeyFor === 'function',
42
58
  };
43
59
  }
44
60
 
@@ -38,7 +38,18 @@ export interface MemoryDriverOptions {
38
38
 
39
39
  const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
40
40
 
41
- export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver {
41
+ /**
42
+ * The in-memory driver's own type: `JobDriver` with `close` REQUIRED.
43
+ *
44
+ * `JobDriver.close` is optional because a driver may hold nothing to release. This one always
45
+ * does — it clears the job map — and every wrapper in the test suite delegates through
46
+ * `base.close()`. Declaring it here is what makes that delegation a CHECKED call: against a plain
47
+ * `JobDriver` the only way to write it is `base.close?.()`, which a driver that quietly stopped
48
+ * shipping a `close` would satisfy in silence.
49
+ */
50
+ export type MemoryJobDriver = JobDriver & { close(): Promise<void> };
51
+
52
+ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJobDriver {
42
53
  const clock = options.clock ?? systemClock;
43
54
  const steps = options.steps ?? createMemoryStepStore();
44
55
  const backfills = options.backfills ?? createMemoryBackfillLedger(clock);
@@ -46,12 +57,20 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
46
57
  options.leases ?? createMemoryLeaseStore(options.clock === undefined ? {} : { clock });
47
58
  const jobs = new Map<string, JobRecord>();
48
59
 
49
- // Keyed by NAME and key, exactly as `x_jobs_name_idempotency_live_idx` is. A global key
50
- // namespace let two unrelated jobs that derived the same natural key dedupe against each
51
- // other: the second enqueue returned the first's id and its work never ran.
52
- const liveByKey = (name: string, key: string): JobRecord | undefined => {
60
+ // Keyed by NAME, TENANT and key, exactly as `x_jobs_name_tenant_idempotency_live_idx` is. A
61
+ // global key namespace let two unrelated jobs that derived the same natural key dedupe against
62
+ // each other: the second enqueue returned the first's id and its work never ran. A tenant-blind
63
+ // one did the same ACROSS tenants, where the id handed back belongs to somebody else and is
64
+ // valid on every id-addressed surface. `?? ''` mirrors the index's `coalesce`, so all tenantless
65
+ // rows share one namespace rather than each becoming its own.
66
+ const liveByKey = (name: string, key: string, tenantId?: string): JobRecord | undefined => {
53
67
  for (const record of jobs.values()) {
54
- if (record.name === name && record.idempotencyKey === key && LIVE_STATES.has(record.state)) {
68
+ if (
69
+ record.name === name &&
70
+ record.idempotencyKey === key &&
71
+ (record.tenantId ?? '') === (tenantId ?? '') &&
72
+ LIVE_STATES.has(record.state)
73
+ ) {
55
74
  return record;
56
75
  }
57
76
  }
@@ -124,7 +143,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
124
143
  introspect,
125
144
 
126
145
  enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
127
- const existing = liveByKey(request.name, request.idempotencyKey);
146
+ const existing = liveByKey(request.name, request.idempotencyKey, request.tenantId);
128
147
  if (existing !== undefined) {
129
148
  if (request.onConflict === 'error') {
130
149
  throw new JobDuplicateError({
@@ -206,7 +225,14 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
206
225
  const at = nowMs(clock);
207
226
  const counts = nackOptions.countsAsAttempt !== false;
208
227
  const patch: Partial<JobRecord> = {
209
- state: nackOptions.deadLetter === true ? 'dead' : counts ? 'ready' : 'suspended',
228
+ // `park`, never `counts`: parking is what leaves the ready bucket, and burning an attempt
229
+ // is a separate fact. A shed sets neither and stays `ready`, which is what it is.
230
+ state:
231
+ nackOptions.deadLetter === true
232
+ ? 'dead'
233
+ : nackOptions.park === true
234
+ ? 'suspended'
235
+ : 'ready',
210
236
  runAt: at + nackOptions.delayMs,
211
237
  // A suspension must not burn an attempt, or a 3-day sleep dead-letters the run. Floored
212
238
  // where `SQL_NACK` floors it (`greatest(attempt - 1, 0)`): the fence above is what keeps
@@ -39,19 +39,33 @@ alter table x_jobs add column if not exists traceparent text;
39
39
 
40
40
  alter table x_jobs add column if not exists enqueued_by text;
41
41
 
42
- -- Partial unique index: one LIVE job per (name, idempotency key). Completed rows stay for
42
+ -- Partial unique index: one LIVE job per (name, tenant, idempotency key). Completed rows stay for
43
43
  -- history, so re-running the same work tomorrow is allowed and re-delivering it today is not.
44
44
  --
45
45
  -- The NAME is in the key, and its absence was silent data loss: two jobs that happened to derive
46
46
  -- the same natural key from the same input ("user:42") shared one namespace, so the second
47
47
  -- enqueue deduped against the FIRST jobs row and returned its id. The work never ran, no error
48
- -- was raised, and the queue showed one healthy job. The old index is dropped rather than left
49
- -- beside the new one — it is strictly narrower, so keeping it would keep enforcing exactly the
50
- -- collision this fixes.
48
+ -- was raised, and the queue showed one healthy job.
49
+ --
50
+ -- The TENANT is in the key for that same argument with one word substituted, and it is the worse
51
+ -- half. Every natural key an app writes is unique only WITHIN a tenant, invoice:1001 and
52
+ -- order:5540 being the shapes the docs suggest, so tenant B enqueuing while tenant A held that
53
+ -- key deduped into tenant A row: tenant B work never ran, and tenant B caller received tenant A
54
+ -- job id, which is valid on every id-addressed surface. Cancel takes an id with no tenant
55
+ -- predicate.
56
+ --
57
+ -- coalesce rather than the bare column, because a null tenant_id compares unequal to every other
58
+ -- null under a unique index and a tenantless queue would lose its dedupe entirely. All tenantless
59
+ -- rows share one namespace instead, which is exactly what they had before tenancy existed.
60
+ --
61
+ -- Each superseded index is dropped rather than left beside the new one. Each is strictly narrower,
62
+ -- so keeping it would keep enforcing exactly the collision this fixes.
51
63
  drop index if exists x_jobs_idempotency_live_idx;
52
64
 
53
- create unique index if not exists x_jobs_name_idempotency_live_idx
54
- on x_jobs (name, idempotency_key)
65
+ drop index if exists x_jobs_name_idempotency_live_idx;
66
+
67
+ create unique index if not exists x_jobs_name_tenant_idempotency_live_idx
68
+ on x_jobs (name, (coalesce(tenant_id, '')), idempotency_key)
55
69
  where state in ('ready', 'delayed', 'running', 'suspended');
56
70
 
57
71
  create index if not exists x_jobs_claim_idx
@@ -3,9 +3,10 @@
3
3
  // decoding a row is not control flow — every number arrives as `number | string` (a bigint is a
4
4
  // string in every client) and every absent column as `null`, and that translation is its own job.
5
5
 
6
- import type { BackfillRun, BackfillStatus } from './backfill-ledger';
7
- import type { JobRecord } from './driver';
8
- import type { StepRecord } from './steps';
6
+ import { BACKFILL_STATUSES, type BackfillRun, isBackfillStatus } from './backfill-ledger';
7
+ import { isJobState, JOB_STATES, type JobRecord, type JobState } from './driver';
8
+ import { JobRowStatusUnknownError } from './errors';
9
+ import { isStepStatus, STEP_STATUSES, type StepRecord, type StepStatus } from './steps';
9
10
 
10
11
  export interface StepRow {
11
12
  readonly run_id: string;
@@ -54,6 +55,21 @@ export interface JobRow {
54
55
  readonly enqueued_by?: string | null;
55
56
  }
56
57
 
58
+ /**
59
+ * The one narrowing for all three status columns. `as` is not a check, and these rows cross a
60
+ * process boundary — a queue row was written by whatever build was deployed when the job was
61
+ * enqueued, which on a rolling deploy is not this one. `isBackfillStatus`'s own doc already stated
62
+ * the rule ("Never a cast — the list decides") and this file was the caller ignoring it.
63
+ */
64
+ const statusIn = <T extends string>(
65
+ known: readonly T[],
66
+ is: (value: string) => value is T,
67
+ input: { table: string; column: string; value: string },
68
+ ): T => {
69
+ if (is(input.value)) return input.value;
70
+ throw new JobRowStatusUnknownError({ ...input, known });
71
+ };
72
+
57
73
  export const num = (value: number | string | null | undefined): number =>
58
74
  value === null || value === undefined ? 0 : Number(value);
59
75
 
@@ -71,7 +87,11 @@ export function toJobRecord(row: JobRow): JobRecord {
71
87
  runId: row.run_id,
72
88
  attempt: row.attempt,
73
89
  maxAttempts: row.max_attempts,
74
- state: row.state as JobRecord['state'],
90
+ state: statusIn<JobState>(JOB_STATES, isJobState, {
91
+ table: 'ultimate_jobs',
92
+ column: 'state',
93
+ value: row.state,
94
+ }),
75
95
  runAt: num(row.run_at),
76
96
  createdAt: num(row.created_at),
77
97
  updatedAt: num(row.updated_at),
@@ -94,7 +114,11 @@ export function toStepRecord(row: StepRow): StepRecord {
94
114
  return {
95
115
  runId: row.run_id,
96
116
  name: row.name,
97
- status: row.status as StepRecord['status'],
117
+ status: statusIn<StepStatus>(STEP_STATUSES, isStepStatus, {
118
+ table: 'ultimate_job_steps',
119
+ column: 'status',
120
+ value: row.status,
121
+ }),
98
122
  output: row.output,
99
123
  startedAt: num(row.started_at),
100
124
  attempts: row.attempts,
@@ -112,7 +136,11 @@ export function toBackfillRun(row: BackfillRow): BackfillRun {
112
136
  runId: row.run_id,
113
137
  name: row.name,
114
138
  checksum: row.checksum,
115
- status: row.status as BackfillStatus,
139
+ status: statusIn(BACKFILL_STATUSES, isBackfillStatus, {
140
+ table: 'ultimate_backfills',
141
+ column: 'status',
142
+ value: row.status,
143
+ }),
116
144
  appVersion: row.app_version,
117
145
  // `rows_processed` is a bigint, which every Postgres client hands back as a string.
118
146
  rows: num(row.rows_processed),
@@ -17,17 +17,26 @@ values
17
17
  ($1, $2, $3, $4::jsonb, $5, $6, $7,
18
18
  case when to_timestamp($8 / 1000.0) > now() then 'delayed' else 'ready' end,
19
19
  to_timestamp($8 / 1000.0), $9, $10, $11)
20
- on conflict (name, idempotency_key)
20
+ on conflict (name, (coalesce(tenant_id, '')), idempotency_key)
21
21
  where state in ('ready', 'delayed', 'running', 'suspended')
22
22
  do nothing
23
23
  returning id, run_id
24
24
  `.trim();
25
25
 
26
- /** Scoped by NAME as well as key — the index is, so a lookup that was not would find a stranger. */
26
+ /**
27
+ * Scoped by NAME and TENANT as well as key, because the index is — a lookup narrower than the
28
+ * index it reads answers with whichever stranger holds the key, and `{ deduped: true, id: <another
29
+ * tenant's> }` is a cross-tenant handle, not just a missed run.
30
+ *
31
+ * `coalesce` on both sides, matching the index expression exactly: a bare `tenant_id = $3` never
32
+ * matches a null row, so every tenantless enqueue would fall through to the "rejected but no live
33
+ * row holds its idempotency key" refusal instead of finding its own live job.
34
+ */
27
35
  export const SQL_FIND_LIVE_BY_KEY = `
28
36
  select id, run_id from x_jobs
29
37
  where name = $1
30
38
  and idempotency_key = $2
39
+ and coalesce(tenant_id, '') = coalesce($3::text, '')
31
40
  and state in ('ready', 'delayed', 'running', 'suspended')
32
41
  limit 1
33
42
  `.trim();
package/src/driver-pg.ts CHANGED
@@ -271,19 +271,22 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
271
271
  return { id: inserted.id, runId: inserted.run_id, deduped: false };
272
272
  }
273
273
 
274
- // `do nothing` fired: a live job OF THIS NAME already owns this idempotency key. The name
275
- // is in the lookup because it is in the index — without it this returned whichever other
276
- // job happened to derive the same natural key, and the caller's work silently never ran.
274
+ // `do nothing` fired: a live job OF THIS NAME, IN THIS TENANT, already owns this idempotency
275
+ // key. Both are in the lookup because both are in the index — without the name this returned
276
+ // whichever other job derived the same natural key; without the tenant it returned another
277
+ // TENANT's row, so the caller's work silently never ran AND the caller was handed an id it
278
+ // has no right to, on a surface (`cancel`) that takes an id with no tenant predicate.
277
279
  const existing = await exec().query<{ id: string; run_id: string }>(SQL_FIND_LIVE_BY_KEY, [
278
280
  request.name,
279
281
  request.idempotencyKey,
282
+ request.tenantId ?? null,
280
283
  ]);
281
284
  const found = existing[0];
282
285
  if (found === undefined) {
283
286
  throw new DriverUnavailableError({
284
287
  driver: 'pg',
285
288
  cause: `enqueue of "${request.name}" was rejected but no live row holds its idempotency key`,
286
- fix: 'x db migrate # reapplies SQL_JOBS_TABLE, whose x_jobs_name_idempotency_live_idx is what this lookup reads',
289
+ fix: 'x db migrate # reapplies SQL_JOBS_TABLE, whose x_jobs_name_tenant_idempotency_live_idx is what this lookup reads',
287
290
  });
288
291
  }
289
292
  if (request.onConflict === 'error') {
@@ -321,7 +324,14 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
321
324
 
322
325
  async nack(jobId: string, nackOptions: NackOptions): Promise<void> {
323
326
  const counts = nackOptions.countsAsAttempt !== false;
324
- const state = nackOptions.deadLetter === true ? 'dead' : counts ? 'ready' : 'suspended';
327
+ // The same three-way the memory driver takes, and it reads `park` rather than `counts`: the
328
+ // attempt counter and the ready bucket are two facts, and a shed only ever meant the first.
329
+ const state =
330
+ nackOptions.deadLetter === true
331
+ ? 'dead'
332
+ : nackOptions.park === true
333
+ ? 'suspended'
334
+ : 'ready';
325
335
  await exec().query(SQL_NACK, [
326
336
  jobId,
327
337
  state,
package/src/driver.ts CHANGED
@@ -17,15 +17,22 @@ import type { StepStore } from './steps';
17
17
  * a cancellation is work an operator stopped on purpose and `x jobs retry` must not resurrect by
18
18
  * accident. It appears in no claim predicate, so the queue never hands a cancelled row out again.
19
19
  */
20
- export type JobState =
21
- | 'ready'
22
- | 'delayed'
23
- | 'running'
24
- | 'suspended'
25
- | 'done'
26
- | 'failed'
27
- | 'dead'
28
- | 'cancelled';
20
+ export const JOB_STATES = [
21
+ 'ready',
22
+ 'delayed',
23
+ 'running',
24
+ 'suspended',
25
+ 'done',
26
+ 'failed',
27
+ 'dead',
28
+ 'cancelled',
29
+ ] as const;
30
+
31
+ export type JobState = (typeof JOB_STATES)[number];
32
+
33
+ /** Narrows a state read back off a queue row. Never a cast — the list decides. */
34
+ export const isJobState = (value: string): value is JobState =>
35
+ (JOB_STATES as readonly string[]).includes(value);
29
36
 
30
37
  export interface JobRecord {
31
38
  readonly id: string;
@@ -114,10 +121,23 @@ export interface NackOptions {
114
121
  readonly delayMs: number;
115
122
  readonly error?: string;
116
123
  /**
117
- * False for a suspension (`step.sleep`): parking a run is not a failure and must not burn
118
- * a retry attempt, or a 3-day sleep would dead-letter the job.
124
+ * The ATTEMPT COUNTER, and nothing else. False for a suspension and for a shed alike: neither is
125
+ * a failure, and a 3-day sleep that burned an attempt would dead-letter the job.
119
126
  */
120
127
  readonly countsAsAttempt?: boolean;
128
+ /**
129
+ * True for a SUSPENSION — `step.sleep`, or a name this deploy does not know — which leaves the
130
+ * ready bucket and is counted `suspended`. Absent for a limiter or `job.concurrency` shed, which
131
+ * is a job still WAITING to run.
132
+ *
133
+ * The two were one flag until 2026-08: `countsAsAttempt: false` decided the state as well as the
134
+ * counter, so a shed was filed beside a 3-day sleep and `stats()` excluded it from `ready` and
135
+ * from `oldestReadyMs` — the two numbers the worker publishes as `queue_depth` and
136
+ * `queue_oldest_ready_seconds`. Under sustained overload the shed fraction approaches 100%, so
137
+ * the HPA signal and the "oldest job older than 5 minutes" page both went quiet exactly when the
138
+ * queue was saturated.
139
+ */
140
+ readonly park?: boolean;
121
141
  readonly deadLetter?: boolean;
122
142
  }
123
143
 
@@ -128,7 +148,11 @@ export interface QueueStats {
128
148
  readonly running: number;
129
149
  readonly suspended: number;
130
150
  readonly dead: number;
131
- /** Age in ms of the oldest claimable job — the number that decides autoscaling. */
151
+ /**
152
+ * Age in ms of the oldest job that is READY and due — the number that decides autoscaling.
153
+ * Not "claimable": a `suspended` row is claimable once its `runAt` passes and is deliberately
154
+ * excluded here, which is exactly why a limiter shed may not be filed as a suspension.
155
+ */
132
156
  readonly oldestReadyMs: number;
133
157
  }
134
158
 
package/src/errors.ts CHANGED
@@ -23,6 +23,8 @@ export const JOB_OWNED_ERROR_CODES = [
23
23
  'X_BACKFILL_RUNNING',
24
24
  'X_BACKFILL_STALLED',
25
25
  'X_BACKFILL_UNKNOWN',
26
+ 'X_JOB_ROW_STATUS_UNKNOWN',
27
+ 'X_ACTION_JOB_UNBRIDGED',
26
28
  ] as const;
27
29
 
28
30
  /**
@@ -58,6 +60,8 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
58
60
  X_BACKFILL_RUNNING: 'a pass under this name is already live',
59
61
  X_BACKFILL_STALLED: 'the sweep ended with rows its own count still matches',
60
62
  X_BACKFILL_UNKNOWN: 'no declaration carries this backfill name',
63
+ X_JOB_ROW_STATUS_UNKNOWN: 'a queue row carries a status this build does not know',
64
+ X_ACTION_JOB_UNBRIDGED: 'an action projection was registered as a job',
61
65
  };
62
66
 
63
67
  // One unconditional call, so a second package claiming one of jobs' codes throws
@@ -89,7 +93,8 @@ registerErrorRetry({
89
93
  X_BACKFILL_APPLIED: 'terminal',
90
94
  });
91
95
 
92
- const docsFor = (code: JobErrorCode): string => `https://ultimate.dev/errors/${code}`;
96
+ /** Shared with `backfill-errors.ts`, which holds the seven `X_BACKFILL_*` classes. */
97
+ export const docsFor = (code: JobErrorCode): string => `https://ultimate.dev/errors/${code}`;
93
98
 
94
99
  /** An enqueue collided with a live job holding the same idempotency key under `onConflict: 'error'`. */
95
100
  export class JobDuplicateError extends UltimateError {
@@ -124,6 +129,57 @@ export class JobNameTakenError extends UltimateError {
124
129
  }
125
130
  }
126
131
 
132
+ /**
133
+ * A `text` status column holds a value outside the vocabulary this build compiled.
134
+ *
135
+ * Refused rather than passed through, because the alternative is what used to happen: the three
136
+ * decoders in `driver-pg-rows.ts` cast the column, and `stepRun`'s `existing?.status ===
137
+ * 'completed'` then read false for the laundered value and RE-EXECUTED the step. A second charge
138
+ * is a worse answer than a failed attempt, and "an unrecognised fact is never a satisfied one" is
139
+ * the rule the rest of the framework already follows.
140
+ *
141
+ * Almost always a NEWER deploy's row, not corruption: a status string only reaches the table
142
+ * because some version of this framework wrote it. A rolling deploy that only ADDS a status is
143
+ * safe in the normal direction — the new build knows every old value — and it is the old build
144
+ * reading the new build's row that lands here, on that one job, loudly.
145
+ */
146
+ export class JobRowStatusUnknownError extends UltimateError {
147
+ constructor(input: { table: string; column: string; value: string; known: readonly string[] }) {
148
+ super({
149
+ code: 'X_JOB_ROW_STATUS_UNKNOWN',
150
+ cause:
151
+ `${input.table}.${input.column} holds "${input.value}", which this build does not know — ` +
152
+ `it reads ${input.known.join(', ')}`,
153
+ fix: `x jobs show --json # then drain the older workers: a status this build cannot read was almost certainly written by a newer deploy`,
154
+ docs: docsFor('X_JOB_ROW_STATUS_UNKNOWN'),
155
+ });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * `registerJobs()` was handed `someAction.job()`.
161
+ *
162
+ * That call answers an `ActionJobHandle` — `kind: 'action-job'`, deliberately a different literal
163
+ * from `'job'` — which is the four fields `job()` takes, not a job. It cannot be one: `action` and
164
+ * `jobs` are both tier 3, so neither may import the other, and only `job()` seats a handle the
165
+ * queue, the worker and the manifest accept.
166
+ *
167
+ * Refused BY NAME rather than skipped, which is what used to happen. `registerJobs(module)` is
168
+ * handed a whole module namespace, so silently ignoring a constant or a helper exported beside a
169
+ * job is right — but ignoring this one meant `registerJobs({ publishPost: publishPost.job() })`
170
+ * registered nothing, returned `[]`, and the job never ran, with nothing failing anywhere.
171
+ */
172
+ export class ActionJobUnbridgedError extends UltimateError {
173
+ constructor(input: { export: string; job: string }) {
174
+ super({
175
+ code: 'X_ACTION_JOB_UNBRIDGED',
176
+ cause: `export "${input.export}" is the action projection "${input.job}", which is not a job handle and cannot be registered as one`,
177
+ fix: `wrap it: agentJob(${input.export}, { name: '${input.export}', tenant, retry }) from @ultimat3/ai — that composes job() and returns a handle the queue accepts`,
178
+ docs: docsFor('X_ACTION_JOB_UNBRIDGED'),
179
+ });
180
+ }
181
+ }
182
+
127
183
  /** Two steps in one run share a name, so replay cannot tell their persisted results apart. */
128
184
  export class StepDuplicateError extends UltimateError {
129
185
  constructor(input: { job: string; step: string }) {
@@ -334,134 +390,6 @@ export class OutboxNoTxError extends UltimateError {
334
390
  }
335
391
  }
336
392
 
337
- /**
338
- * The seven backfill codes below all answer one question — "why is this sweep not running?" — and
339
- * each is here because it sends the reader somewhere different: run it, force it, change
340
- * environment, migrate first, wait, fix the predicates, or fix the name. A code that shared a fix
341
- * line with another one would be code inflation, which is why `X_BACKFILL_WRITE_UNCONFIRMED` was
342
- * considered and rejected: a dry run that wrote nothing did exactly what it was asked to.
343
- *
344
- * Every `fix` below is ONE line something can execute — a shell command, or the edit to make.
345
- * Never a command with prose appended: a `fix:` is copied and run verbatim, so a trailing clause
346
- * turns a working command into a syntax error at the one moment the reader is following it
347
- * literally. Explanations belong in `cause`, which is read and never run.
348
- */
349
-
350
- /**
351
- * Declared and never completed. The alarm the framework did not have: an author could
352
- * `x g backfill`, merge and deploy, and nothing anywhere said the pass had not run.
353
- */
354
- export class BackfillPendingError extends UltimateError {
355
- constructor(input: { backfill: string; environment: string }) {
356
- super({
357
- code: 'X_BACKFILL_PENDING',
358
- cause: `backfill "${input.backfill}" is declared and x_backfills holds no completed pass for it in ${input.environment}`,
359
- fix: `x db backfill ${input.backfill} --write --json`,
360
- docs: docsFor('X_BACKFILL_PENDING'),
361
- });
362
- }
363
- }
364
-
365
- /** Already swept. A rerun is legitimate, so this names the flag rather than refusing outright. */
366
- export class BackfillAppliedError extends UltimateError {
367
- constructor(input: { backfill: string; runId: string; completedAt: string }) {
368
- super({
369
- code: 'X_BACKFILL_APPLIED',
370
- cause: `backfill "${input.backfill}" completed as run ${input.runId} at ${input.completedAt}; a forced rerun writes a NEW ledger row and never edits that one`,
371
- fix: `x db backfill ${input.backfill} --write --force --json`,
372
- docs: docsFor('X_BACKFILL_APPLIED'),
373
- });
374
- }
375
- }
376
-
377
- /**
378
- * The declaration names the environments it belongs to and this is not one. Declared DATA, never a
379
- * hardcoded "cleanups are production": a staging rehearsal is correct practice, so which
380
- * environments a sweep belongs to is the app's convention and this is only the mechanism carrying
381
- * it (axiom 8).
382
- */
383
- export class BackfillEnvironmentError extends UltimateError {
384
- constructor(input: { backfill: string; environment: string; declared: readonly string[] }) {
385
- // The first declared environment, because the fix has to be ONE runnable line and the list is
386
- // ordered by the author. The empty case cannot arise from `checkBackfillEnvironment`, which
387
- // treats an empty list as "every environment" — but this constructor is public, so it answers
388
- // with the command that lists what IS declared rather than an `ULTIMATE_ENV=undefined`.
389
- const target = input.declared[0];
390
- super({
391
- code: 'X_BACKFILL_ENVIRONMENT',
392
- cause: `backfill "${input.backfill}" declares environments: ${input.declared.join(', ')} and this process resolved ${input.environment} — add "${input.environment}" to that list if this deploy should sweep too`,
393
- fix:
394
- target === undefined
395
- ? 'x db backfill --pending --json'
396
- : `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
397
- docs: docsFor('X_BACKFILL_ENVIRONMENT'),
398
- });
399
- }
400
- }
401
-
402
- /**
403
- * `requires` names a migration the ledger has not applied. Checked where `x_migrations` is
404
- * readable — this package holds no `@ultimat3/db` dependency and growing one to read a ledger
405
- * would put the migration engine on the tier-3 queue's import graph.
406
- */
407
- export class BackfillMigrationPendingError extends UltimateError {
408
- constructor(input: { backfill: string; migration: string }) {
409
- super({
410
- code: 'X_BACKFILL_MIGRATION_PENDING',
411
- cause: `backfill "${input.backfill}" requires migration ${input.migration}, which x_migrations does not record as applied`,
412
- fix: 'x db migrate --json',
413
- docs: docsFor('X_BACKFILL_MIGRATION_PENDING'),
414
- });
415
- }
416
- }
417
-
418
- /**
419
- * The enqueue deduped: one live pass per name, so this run is the one already going. Distinct from
420
- * `X_BACKFILL_APPLIED`, which is a pass that finished — the response there is `--force`, and the
421
- * response here is to look at the run that is holding the key.
422
- */
423
- export class BackfillRunningError extends UltimateError {
424
- constructor(input: { backfill: string; jobId: string }) {
425
- super({
426
- code: 'X_BACKFILL_RUNNING',
427
- cause: `backfill "${input.backfill}" already has a live pass queued as ${input.jobId}, and one name holds one live pass; its step trace names the batch it is on, and a pass that is not advancing is a worker that lost its lease`,
428
- fix: `x jobs show ${input.jobId} --json`,
429
- docs: docsFor('X_BACKFILL_RUNNING'),
430
- });
431
- }
432
- }
433
-
434
- /**
435
- * The source ran out of rows and the declaration's own `count()` still matches some. Two
436
- * predicates that disagree is an authoring bug in any business — the sweep reported success over
437
- * rows it never visited — so the pass fails rather than writing a completed row nobody can trust.
438
- */
439
- export class BackfillStalledError extends UltimateError {
440
- constructor(input: { backfill: string; remaining: number; swept: number }) {
441
- super({
442
- code: 'X_BACKFILL_STALLED',
443
- cause: `backfill "${input.backfill}" swept ${input.swept} rows, exhausted its source, and count() still matches ${input.remaining} — a WHERE the sweep narrows and the count does not is what leaves rows behind`,
444
- fix: `make count() select on exactly what source() selects on in backfill("${input.backfill}")`,
445
- docs: docsFor('X_BACKFILL_STALLED'),
446
- });
447
- }
448
- }
449
-
450
- /** A name no declaration in this app carries — a typo, or a backfill whose module was deleted. */
451
- export class BackfillUnknownError extends UltimateError {
452
- constructor(input: { backfill: string; known: readonly string[] }) {
453
- super({
454
- code: 'X_BACKFILL_UNKNOWN',
455
- cause:
456
- input.known.length === 0
457
- ? `no backfill named "${input.backfill}" is declared, and this app declares none at all`
458
- : `no backfill named "${input.backfill}" is declared (declared: ${input.known.join(', ')})`,
459
- fix: 'x db backfill --pending --json',
460
- docs: docsFor('X_BACKFILL_UNKNOWN'),
461
- });
462
- }
463
- }
464
-
465
393
  export class JobsNotImplementedError extends UltimateError {
466
394
  constructor(input: { feature: string; fix: string }) {
467
395
  super({
package/src/execute.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // One claimed job run to completion, suspension or failure and settled with the driver — the
2
- // single execution path the worker loop and `x jobs run` share. It owns the run's deadline, and
2
+ // single execution path, shared by the worker loop (`worker-run.ts`) and by the one caller outside
3
+ // this package, `@ultimat3/testing`'s job fixture. There is no `x jobs run` to share it with: the
4
+ // subcommands are `ls`, `show`, `retry`, `cancel`, `drain`. It owns the run's deadline, and
3
5
  // a deadline here means CANCEL: the nack that follows makes the job claimable again, so a body
4
6
  // still running past it would be a second copy of one job, racing the attempt that replaced it.
5
7
 
@@ -87,7 +89,8 @@ export interface ExecuteJobOptions {
87
89
 
88
90
  /**
89
91
  * Run one claimed job to completion, suspension or failure, and settle it with the driver.
90
- * Shared by the worker loop and `x jobs run` so both take exactly the same code path.
92
+ * Shared by the worker loop and by `@ultimat3/testing`'s job fixture, so a job under test takes
93
+ * exactly the code path the worker takes.
91
94
  */
92
95
  export async function executeJob(options: ExecuteJobOptions): Promise<JobExecution> {
93
96
  const { driver, claimed, handle } = options;
@@ -159,8 +162,10 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
159
162
  } catch (error) {
160
163
  if (isStepSuspension(error)) {
161
164
  const delayMs = Math.max(0, error.resumeAt - nowMs(options.clock));
162
- // countsAsAttempt: falseparking a run is not a failure.
163
- await driver.nack(claimed.id, { delayMs, countsAsAttempt: false });
165
+ // `park: true` is the suspension itself the row leaves the ready bucket — and
166
+ // `countsAsAttempt: false` only says not to burn an attempt on it. A limiter shed passes the
167
+ // second and not the first: it is a job still waiting, and it belongs in `queue_depth`.
168
+ await driver.nack(claimed.id, { delayMs, countsAsAttempt: false, park: true });
164
169
  return settle({
165
170
  outcome: 'suspended',
166
171
  jobId: claimed.id,
@@ -200,8 +205,8 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
200
205
  // This package's ONE error-reporting call site, and it is here rather than in the loop because
201
206
  // this is the only frame that still holds the thrown value — the loop sees a message string.
202
207
  // A retry is a failure the framework recovered from, so it is a `warning`; a dead letter is
203
- // one nobody recovered from. `x jobs run` takes this path too, which is the point: one
204
- // execution path means one place a failed job can become visible.
208
+ // one nobody recovered from. A job driven by `@ultimat3/testing`'s fixture takes this path
209
+ // too, which is the point: one execution path means one place a failed job becomes visible.
205
210
  reportError(error, {
206
211
  source: 'job',
207
212
  severity: decision.retry ? 'warning' : 'error',
package/src/index.ts CHANGED
@@ -17,6 +17,15 @@ export type {
17
17
  BackfillReport,
18
18
  } from './backfill';
19
19
  export { backfill, DEFAULT_BACKFILL_BATCH } from './backfill';
20
+ export {
21
+ BackfillAppliedError,
22
+ BackfillEnvironmentError,
23
+ BackfillMigrationPendingError,
24
+ BackfillPendingError,
25
+ BackfillRunningError,
26
+ BackfillStalledError,
27
+ BackfillUnknownError,
28
+ } from './backfill-errors';
20
29
  export type { BackfillGate, BackfillGateInput } from './backfill-gate';
21
30
  export { checkBackfillEnvironment, gateBackfill } from './backfill-gate';
22
31
  export type { BackfillProgress } from './backfill-inspect';
@@ -77,11 +86,13 @@ export type {
77
86
  export {
78
87
  DEFAULT_QUEUE,
79
88
  DEFAULT_VISIBILITY_TIMEOUT_MS,
89
+ isJobState,
90
+ JOB_STATES,
80
91
  jobDriver,
81
92
  resetJobDriver,
82
93
  setJobDriver,
83
94
  } from './driver';
84
- export type { MemoryDriverOptions } from './driver-memory';
95
+ export type { MemoryDriverOptions, MemoryJobDriver } from './driver-memory';
85
96
  export { createMemoryDriver } from './driver-memory';
86
97
  export type { NatsDriverOptions } from './driver-nats';
87
98
  export { createNatsDriver } from './driver-nats';
@@ -121,13 +132,7 @@ export type { RedisDriverOptions } from './driver-redis';
121
132
  export { createRedisDriver } from './driver-redis';
122
133
  export type { JobErrorCode } from './errors';
123
134
  export {
124
- BackfillAppliedError,
125
- BackfillEnvironmentError,
126
- BackfillMigrationPendingError,
127
- BackfillPendingError,
128
- BackfillRunningError,
129
- BackfillStalledError,
130
- BackfillUnknownError,
135
+ ActionJobUnbridgedError,
131
136
  CancelUnsupportedError,
132
137
  ConcurrencyUnenforceableError,
133
138
  DriverUnavailableError,
@@ -139,6 +144,7 @@ export {
139
144
  JobMaxAttemptsError,
140
145
  JobNameTakenError,
141
146
  JobNotCancellableError,
147
+ JobRowStatusUnknownError,
142
148
  JobSlotLostError,
143
149
  JobsNotImplementedError,
144
150
  JobTenantRequiredError,
@@ -248,8 +254,10 @@ export type {
248
254
  export {
249
255
  createMemoryStepStore,
250
256
  createStepRunner,
257
+ isStepStatus,
251
258
  isStepSuspension,
252
259
  MAX_TRACE_NAMES,
260
+ STEP_STATUSES,
253
261
  StepSuspension,
254
262
  } from './steps';
255
263
  export type {
package/src/metrics.ts CHANGED
@@ -18,7 +18,7 @@ import { gauge } from '@ultimat3/core';
18
18
  /** Seconds and not milliseconds: every Prometheus duration is seconds, and the alert is `> 300`. */
19
19
  export const queueOldestReady: Gauge = gauge('queue_oldest_ready_seconds', {
20
20
  unit: 's',
21
- description: 'Age of the oldest claimable job, by queue — 0 when the queue is empty',
21
+ description: 'Age of the oldest job that is ready and due, by queue — 0 when none is',
22
22
  });
23
23
 
24
24
  export const queueDeadJobs: Gauge = gauge('queue_dead_jobs', {
package/src/register.ts CHANGED
@@ -5,9 +5,24 @@
5
5
  */
6
6
 
7
7
  import { type RegisteredPrimitive, registerPrimitiveRegistrar } from '@ultimat3/core';
8
+ import { ActionJobUnbridgedError } from './errors';
8
9
  import { isJobHandle, registerJob } from './job';
9
10
  import { isTaskHandle, registerTask } from './task';
10
11
 
12
+ /**
13
+ * `@ultimat3/action`'s job PROJECTION, recognised structurally because that package is this tier
14
+ * and may never be imported here. `kind: 'action-job'` is a literal chosen to be distinguishable
15
+ * from `'job'` rather than a near-miss (`packages/action/src/job-handle.ts` says so), which is
16
+ * precisely what makes this check possible without an import.
17
+ */
18
+ const isActionProjection = (
19
+ value: unknown,
20
+ ): value is { readonly kind: 'action-job'; readonly name: string } =>
21
+ typeof value === 'object' &&
22
+ value !== null &&
23
+ 'kind' in value &&
24
+ (value as { readonly kind: unknown }).kind === 'action-job';
25
+
11
26
  /** `registerJobs(await import('./jobs'))` — export names become job names. */
12
27
  export function registerJobs(
13
28
  module: Readonly<Record<string, unknown>>,
@@ -15,7 +30,16 @@ export function registerJobs(
15
30
  const registered: RegisteredPrimitive[] = [];
16
31
  for (const name of Object.keys(module).sort()) {
17
32
  const value = module[name];
18
- if (isJobHandle(value)) registered.push(registerJob(name, value));
33
+ if (isJobHandle(value)) {
34
+ registered.push(registerJob(name, value));
35
+ continue;
36
+ }
37
+ // Everything else is skipped in silence — a module namespace is full of constants, types and
38
+ // helpers exported beside the jobs. Everything else EXCEPT this one, which is unambiguously
39
+ // someone trying to queue an action and getting nothing at all.
40
+ if (isActionProjection(value)) {
41
+ throw new ActionJobUnbridgedError({ export: name, job: value.name });
42
+ }
19
43
  }
20
44
  return registered;
21
45
  }
package/src/retry.ts CHANGED
@@ -1,6 +1,7 @@
1
- // Retry arithmetic, kept pure so the schedule is testable and printable. `x jobs schedule`
2
- // renders `retrySchedule()` verbatim — an agent should be able to see when attempt 5 lands
3
- // without running the queue.
1
+ // Retry arithmetic, kept pure so the schedule is testable and printable. `x jobs show <id>`
2
+ // renders `retrySchedule()` verbatim as `JobTrace.retryDelaysMs` — an agent should be able to see
3
+ // when attempt 5 lands without running the queue. There is no `x jobs schedule`: the subcommands
4
+ // are `ls`, `show`, `retry`, `cancel`, `drain`.
4
5
 
5
6
  import type { DurationInput } from './clock';
6
7
  import { toMs } from './clock';
package/src/steps.ts CHANGED
@@ -12,7 +12,20 @@ import type { DurationInput } from './clock';
12
12
  import { nowMs, toMs } from './clock';
13
13
  import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
14
14
 
15
- export type StepStatus = 'completed' | 'sleeping' | 'waiting' | 'failed';
15
+ /**
16
+ * The runtime list is the declaration and `StepStatus` is derived from it, the shape
17
+ * `BACKFILL_STATUSES` and `PRIMITIVE_KINDS` already have. A bare union cannot narrow a `text`
18
+ * column, so `driver-pg-rows.ts` cast one instead — and a cast that lands an unknown status on a
19
+ * record makes `stepRun`'s `existing?.status === 'completed'` false, which RE-EXECUTES a step
20
+ * this file promises runs once.
21
+ */
22
+ export const STEP_STATUSES = ['completed', 'sleeping', 'waiting', 'failed'] as const;
23
+
24
+ export type StepStatus = (typeof STEP_STATUSES)[number];
25
+
26
+ /** Narrows a status read back out of a store. Never a cast — the list decides. */
27
+ export const isStepStatus = (value: string): value is StepStatus =>
28
+ (STEP_STATUSES as readonly string[]).includes(value);
16
29
 
17
30
  export interface StepRecord {
18
31
  readonly runId: string;
package/src/task.ts CHANGED
@@ -54,6 +54,7 @@ export interface TaskDefinition {
54
54
  */
55
55
  enqueue: (occurrenceMs: number) => readonly TaskEnqueueEntry[];
56
56
  readonly catchUp?: CatchUpPolicy;
57
+ /** Whole occurrences per round, one or more. Zero fires nothing at all, and `task()` refuses it. */
57
58
  readonly maxCatchUp?: number;
58
59
  }
59
60
 
@@ -98,6 +99,9 @@ export interface TaskHandle {
98
99
  const registry = new Map<string, TaskHandle>();
99
100
  let anonymous = 0;
100
101
 
102
+ /** Occurrences one round may fire when neither the declaration nor a catch-up says otherwise. */
103
+ const DEFAULT_MAX_CATCH_UP = 10;
104
+
101
105
  /** Job's store, for tasks: proof `task()` built the handle, plus whether it named itself. */
102
106
  interface TaskOrigin {
103
107
  readonly declaredName: boolean;
@@ -127,13 +131,26 @@ export function task(definition: TaskDefinition): TaskHandle {
127
131
  `use the full zone id on task("${name}"), e.g. tz: 'America/Bogota' — list the valid ones with: bun -e "console.log(Intl.supportedValuesOf('timeZone').join('\\n'))"`,
128
132
  );
129
133
 
134
+ // `maxCatchUp: 0` is not "no ceiling" — `occurrencesSince` walks
135
+ // `for (let i = 0; i < handle.maxCatchUp; i += 1)`, so zero (and any negative, and any fraction
136
+ // below one) returns an empty list on every round and the task NEVER fires: no error, no log
137
+ // line, no queue row, forever. Refused where it is written, exactly as `job()` refuses
138
+ // `concurrency: 0` and `createPacer` refuses `rate: 0`. `Number.isInteger` covers `NaN` and
139
+ // `Infinity` in the same predicate — an unbounded catch-up is a burst nobody declared.
140
+ assert(
141
+ definition.maxCatchUp === undefined ||
142
+ (Number.isInteger(definition.maxCatchUp) && definition.maxCatchUp >= 1),
143
+ `task "${name}" declares maxCatchUp ${String(definition.maxCatchUp)}, so no occurrence can ever fire`,
144
+ `set a whole maxCatchUp of 1 or more on task("${name}"), or omit the field for the default of ${DEFAULT_MAX_CATCH_UP}`,
145
+ );
146
+
130
147
  const handle: TaskHandle = {
131
148
  kind: 'task',
132
149
  name,
133
150
  cron: definition.cron,
134
151
  tz: definition.tz,
135
152
  catchUp: definition.catchUp ?? 'skip',
136
- maxCatchUp: definition.maxCatchUp ?? 10,
153
+ maxCatchUp: definition.maxCatchUp ?? DEFAULT_MAX_CATCH_UP,
137
154
  // `nowMs()` and not `Date.now()`: every reading of time in this package goes through a
138
155
  // Clock so a frozen one cannot be bypassed.
139
156
  entries: (occurrenceMs: number = nowMs()) => definition.enqueue(occurrenceMs),
package/src/worker-run.ts CHANGED
@@ -48,10 +48,13 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
48
48
  const handle = getJob(claimed.name);
49
49
  if (handle === undefined) {
50
50
  // Park it, do not burn attempts: the job may well be registered by the pod next to this one.
51
+ // A genuine park — no worker in this deploy can run it — so it leaves the ready bucket, unlike
52
+ // a limiter shed, which is a job this fleet will pick up on its next pass.
51
53
  await driver.nack(claimed.id, {
52
54
  delayMs: 30_000,
53
55
  error: `no job registered as "${claimed.name}"`,
54
56
  countsAsAttempt: false,
57
+ park: true,
55
58
  });
56
59
  return unknownJob(claimed);
57
60
  }
package/src/worker.ts CHANGED
@@ -161,6 +161,26 @@ export function createWorker(options: WorkerOptions): Worker {
161
161
  ...(options.events === undefined ? {} : { events: options.events }),
162
162
  });
163
163
 
164
+ /**
165
+ * A claimed job handed straight back over a cap. It is NOT a suspension and NOT a failure: no
166
+ * `park`, so the row stays where `queue_depth` and `queue_oldest_ready_seconds` can see it, and
167
+ * no `error`, so `x jobs show` does not report a `lastError` for a job that never ran. It was
168
+ * both of those until 2026-08 — parked beside a 3-day `step.sleep`, and stamped with a failure
169
+ * it never had — which is why the two sheds go through one function now.
170
+ */
171
+ const shed = async (
172
+ claimed: ClaimedJob,
173
+ detail: { readonly queue: string; readonly reason: string },
174
+ ): Promise<void> => {
175
+ logger.debug('jobs.worker.shed', {
176
+ workerId,
177
+ job: claimed.name,
178
+ jobId: claimed.id,
179
+ ...detail,
180
+ });
181
+ await options.driver.nack(claimed.id, { delayMs: pollIntervalMs, countsAsAttempt: false });
182
+ };
183
+
164
184
  /** The drain's one question: may this worker still take work off the queue? */
165
185
  const claiming = (): boolean => state !== 'draining' && state !== 'stopped';
166
186
 
@@ -196,11 +216,17 @@ export function createWorker(options: WorkerOptions): Worker {
196
216
  ...(job.tenantId === undefined ? {} : { tenantId: job.tenantId }),
197
217
  });
198
218
  if (lease === undefined) {
199
- // Over a tenant/queue/global cap: hand it straight back for another worker.
200
- await options.driver.nack(job.id, {
201
- delayMs: pollIntervalMs,
202
- countsAsAttempt: false,
203
- error: `limited: ${limiter.blockedBy({ queue, ...(job.tenantId === undefined ? {} : { tenantId: job.tenantId }) }) ?? 'unknown'}`,
219
+ // Over a tenant/queue/global cap: hand it straight back for another worker. No `park`
220
+ // and no `error` — the row stays in the ready bucket the depth gauge reads, and nothing
221
+ // about this job failed, so `x jobs show` must not report a `lastError` for it. The
222
+ // reason is a log FIELD instead, where it costs nothing when nobody is asking.
223
+ await shed(job, {
224
+ queue,
225
+ reason:
226
+ limiter.blockedBy({
227
+ queue,
228
+ ...(job.tenantId === undefined ? {} : { tenantId: job.tenantId }),
229
+ }) ?? 'unknown',
204
230
  });
205
231
  continue;
206
232
  }
@@ -223,10 +249,9 @@ export function createWorker(options: WorkerOptions): Worker {
223
249
  }
224
250
  if (!granted) {
225
251
  lease.release();
226
- await options.driver.nack(job.id, {
227
- delayMs: pollIntervalMs,
228
- countsAsAttempt: false,
229
- error: `limited: job concurrency (${getJob(job.name)?.concurrency ?? 0})`,
252
+ await shed(job, {
253
+ queue,
254
+ reason: `job concurrency (${getJob(job.name)?.concurrency ?? 0})`,
230
255
  });
231
256
  continue;
232
257
  }