@ultimat3/jobs 3.0.0 → 4.1.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.1.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.1.0",
36
+ "@ultimat3/entity": "4.1.0",
37
+ "@ultimat3/schema": "4.1.0",
38
+ "@ultimat3/time": "4.1.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
@@ -16,10 +16,13 @@ import type {
16
16
  import { JobsNotImplementedError } from './errors';
17
17
  import type { StepRecord, StepStore } from './steps';
18
18
 
19
- // Names the config edit that actually removes the stub, plus the runnable command for whatever
20
- // is already queued. The nats driver lands in v2; there is no flag that turns this one on.
19
+ // Names the seam that actually replaces the stub, plus the runnable command for whatever is
20
+ // already queued. NOT `jobs: { driver }` in app.config.ts, which this line said until 2026-08-20:
21
+ // `JobsConfig.driver` has no reader anywhere (see `driver.ts`'s header), so that edit repairs
22
+ // nothing and the reader is sent back to the same throw. #223 removes the field.
23
+ // The nats driver lands in v2; there is no flag that turns this one on.
21
24
  const FIX =
22
- "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json";
25
+ 'call setJobDriver(createPgDriver()) at boot instead of this driver, then move what is already queued: x jobs drain --to memory --json';
23
26
 
24
27
  const unavailable = (method: string): never => {
25
28
  throw new JobsNotImplementedError({ feature: `nats jobs driver (${method})`, fix: FIX });
@@ -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,
@@ -19,10 +19,13 @@ import type {
19
19
  import { JobsNotImplementedError } from './errors';
20
20
  import type { StepRecord, StepStore } from './steps';
21
21
 
22
- // Names the config edit that actually removes the stub, plus the runnable command for whatever
23
- // is already queued. The redis driver lands in v2; there is no flag that turns this one on.
22
+ // Names the seam that actually replaces the stub, plus the runnable command for whatever is
23
+ // already queued. NOT `jobs: { driver }` in app.config.ts, which this line said until 2026-08-20:
24
+ // `JobsConfig.driver` has no reader anywhere (see `driver.ts`'s header), so that edit repairs
25
+ // nothing and the reader is sent back to the same throw. #223 removes the field.
26
+ // The redis driver lands in v2; there is no flag that turns this one on.
24
27
  const FIX =
25
- "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json";
28
+ 'call setJobDriver(createPgDriver()) at boot instead of this driver, then move what is already queued: x jobs drain --to memory --json';
26
29
 
27
30
  const unavailable = (method: string): never => {
28
31
  throw new JobsNotImplementedError({ feature: `redis jobs driver (${method})`, fix: FIX });