@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 +53 -15
- package/package.json +5 -5
- package/src/backfill-errors.ts +137 -0
- package/src/backfill-gate.ts +5 -5
- package/src/backfill-pass.ts +1 -1
- package/src/describe.ts +17 -1
- package/src/driver-memory.ts +34 -8
- package/src/driver-nats.ts +6 -3
- package/src/driver-pg-ddl.ts +20 -6
- package/src/driver-pg-rows.ts +34 -6
- package/src/driver-pg-sql.ts +11 -2
- package/src/driver-pg.ts +15 -5
- package/src/driver-redis.ts +6 -3
- package/src/driver.ts +36 -12
- package/src/errors.ts +59 -131
- package/src/execute.ts +11 -6
- package/src/index.ts +16 -8
- package/src/inspect.ts +1 -1
- package/src/metrics.ts +1 -1
- package/src/outbox.ts +1 -1
- package/src/register.ts +25 -1
- package/src/retry.ts +4 -3
- package/src/steps.ts +14 -1
- package/src/task.ts +18 -1
- package/src/worker-run.ts +3 -0
- package/src/worker.ts +34 -9
package/CLAUDE.md
CHANGED
|
@@ -35,16 +35,35 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
35
35
|
A look-alike never registers. Deliberately not a registry lookup — the registry is what
|
|
36
36
|
registration rewrites.
|
|
37
37
|
- `idempotencyKey` is NON-OPTIONAL in `JobDefinition`. Never relax it, never default it.
|
|
38
|
-
- **The idempotency namespace is `(name, idempotency_key)`, never the key
|
|
39
|
-
2026-08`).
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
- **The idempotency namespace is `(name, coalesce(tenant_id, ''), idempotency_key)`, never the key
|
|
39
|
+
alone and never name-only** (`As of 2026-08`). Two rounds of the same defect, and both are silent
|
|
40
|
+
data loss with no error anywhere.
|
|
41
|
+
|
|
42
|
+
It was the key alone: two jobs deriving the same natural key from the same input —
|
|
43
|
+
`sendWelcomeEmail` and `provisionWorkspace` both keyed `user:${id}` — shared one namespace, so
|
|
44
|
+
the second enqueue hit `on conflict do nothing`, fell through to `SQL_FIND_LIVE_BY_KEY`, found
|
|
45
|
+
the FIRST job's row and returned `{ id: <A's>, deduped: true }`. The workspace was never
|
|
46
|
+
provisioned and `x jobs ls` showed one healthy job.
|
|
47
|
+
|
|
48
|
+
Then it was name-only, while the row already carried `tenant_id` as `$9` of the same insert.
|
|
49
|
+
Every natural key the docs suggest is unique only WITHIN a tenant — `` `invoice:${input.invoiceId}` ``,
|
|
50
|
+
`` `order:${input.orderNumber}` `` — so tenant B enqueuing while tenant A held that key deduped
|
|
51
|
+
into tenant A's row: B's work never ran AND B's caller received A's job id, which is valid on
|
|
52
|
+
every id-addressed surface (`cancelJob(driver, jobId)` takes an id with no tenant predicate, so
|
|
53
|
+
an app wiring the returned id to a cancel button gave B cancellation of A's job). The sibling
|
|
54
|
+
projection in `@ultimat3/action` (`idempotency-key.ts`) had folded the actor's org in all along.
|
|
55
|
+
`coalesce`, not the bare column: a null `tenant_id` compares unequal to every other null under a
|
|
56
|
+
unique index, so a tenantless queue would lose its dedupe entirely — all tenantless rows share
|
|
57
|
+
one namespace instead, which is what they had before tenancy existed.
|
|
58
|
+
|
|
59
|
+
Three places have to agree and a test pins them together: the index, the conflict target, and the
|
|
60
|
+
live-row lookup. The conflict target must spell the index EXPRESSION exactly —
|
|
61
|
+
`(name, (coalesce(tenant_id, '')), idempotency_key)` — or Postgres cannot infer the index at all.
|
|
62
|
+
`driver-memory.ts` mirrors it with `(record.tenantId ?? '')`, which is the parity that turned a
|
|
63
|
+
gap into a confirmed one rather than catching it. `x_jobs` SHIPPED, so the DDL
|
|
64
|
+
`drop index if exists`es BOTH superseded indexes — each is strictly narrower than its successor,
|
|
65
|
+
so either left in place would keep enforcing exactly the collision this fixes. The scheduler's
|
|
66
|
+
occurrence key already prefixes the task name and is unaffected.
|
|
48
67
|
- **`SQL_JOBS_TABLE` is the ONE install point, and every durable table this package owns is in
|
|
49
68
|
it** (`As of 2026-08`): `x_jobs`, `x_job_steps`, `x_backfills`, `x_outbox`, `x_scheduler_state`,
|
|
50
69
|
`x_scheduler_leader`, `x_job_leases`, `x_job_events`. Four of those were subsystems that shipped
|
|
@@ -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.
|
|
461
|
-
|
|
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
|
|
532
|
-
|
|
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
|
-
| `
|
|
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
|
+
"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": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
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
|
+
}
|
package/src/backfill-gate.ts
CHANGED
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
// convention rather than a rail (axiom 3).
|
|
9
9
|
|
|
10
10
|
import type { Environment, UltimateError } from '@ultimat3/core';
|
|
11
|
-
// `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
|
|
12
|
-
// own row shape, which would make this a second reader of `x_backfills`.
|
|
13
|
-
import type { BackfillProgress } from './backfill-inspect';
|
|
14
|
-
import type { BackfillDeclaration } from './backfill-registry';
|
|
15
11
|
import {
|
|
16
12
|
BackfillAppliedError,
|
|
17
13
|
BackfillEnvironmentError,
|
|
18
14
|
BackfillMigrationPendingError,
|
|
19
|
-
} from './errors';
|
|
15
|
+
} from './backfill-errors';
|
|
16
|
+
// `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
|
|
17
|
+
// own row shape, which would make this a second reader of `x_backfills`.
|
|
18
|
+
import type { BackfillProgress } from './backfill-inspect';
|
|
19
|
+
import type { BackfillDeclaration } from './backfill-registry';
|
|
20
20
|
|
|
21
21
|
export type BackfillGate =
|
|
22
22
|
| { readonly run: true }
|
package/src/backfill-pass.ts
CHANGED
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
import { appVersion, assert, logger, resolveEnvironment } from '@ultimat3/core';
|
|
17
17
|
import type { BatchIterator } from '@ultimat3/entity';
|
|
18
18
|
import type { BackfillDefinition, BackfillInput, BackfillReport } from './backfill';
|
|
19
|
+
import { BackfillStalledError } from './backfill-errors';
|
|
19
20
|
import { checkBackfillEnvironment } from './backfill-gate';
|
|
20
21
|
import type { BackfillLedger, BackfillRun } from './backfill-ledger';
|
|
21
22
|
import { decideBackfill } from './backfill-ledger';
|
|
22
23
|
import type { Pacer } from './backfill-rate';
|
|
23
24
|
import { withBackfillScope } from './backfill-scope';
|
|
24
25
|
import { jobDriver } from './driver';
|
|
25
|
-
import { BackfillStalledError } from './errors';
|
|
26
26
|
import type { JobRunArgs } from './job';
|
|
27
27
|
import { isStepSuspension } from './steps';
|
|
28
28
|
|
package/src/describe.ts
CHANGED
|
@@ -14,10 +14,18 @@ export interface JobDescriptor {
|
|
|
14
14
|
readonly queue: string;
|
|
15
15
|
readonly retry: { readonly attempts: number; readonly backoff: BackoffStrategy };
|
|
16
16
|
readonly steps: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Whether a replayed attempt is safe to run — `job()` REQUIRES an `idempotencyKey` and refuses
|
|
19
|
+
* a definition without one (`X_IDEMPOTENCY_REQUIRED`), so this is `true` for every registered
|
|
20
|
+
* job. That is the point: the guarantee, published where an operator asks the question, rather
|
|
21
|
+
* than left as prose in a doc. The KEY itself never crosses — it is computed from an input and
|
|
22
|
+
* is app data, so a descriptor carrying it would put customer ids in `x.manifest.json`.
|
|
23
|
+
*/
|
|
24
|
+
readonly idempotent: boolean;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
|
-
* Narrower than `JobHandle` on purpose: the projection reads
|
|
28
|
+
* Narrower than `JobHandle` on purpose: the projection reads five declared fields, so keeping
|
|
21
29
|
* it structural means it never has to carry — or vary with — the handle's input generic.
|
|
22
30
|
*/
|
|
23
31
|
export interface DescribableJob {
|
|
@@ -25,6 +33,13 @@ export interface DescribableJob {
|
|
|
25
33
|
readonly queue: string;
|
|
26
34
|
readonly retry: RetryPolicy;
|
|
27
35
|
readonly input: unknown;
|
|
36
|
+
/**
|
|
37
|
+
* `JobHandle.idempotencyKeyFor`, read only for its presence — `unknown` because the real
|
|
38
|
+
* signature is `(input: I) => string` and this shape is deliberately free of the generic.
|
|
39
|
+
* Required, not optional: a descriptor built without it would publish `idempotent: false`,
|
|
40
|
+
* which is the exact wrong answer the `/_x` jobs panel used to give for every job.
|
|
41
|
+
*/
|
|
42
|
+
readonly idempotencyKeyFor: unknown;
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
export function describeJob(handle: DescribableJob): JobDescriptor {
|
|
@@ -39,6 +54,7 @@ export function describeJob(handle: DescribableJob): JobDescriptor {
|
|
|
39
54
|
// Empty by design: step names are chosen inside `run()` at execution time, so they are
|
|
40
55
|
// not statically knowable. `inspect(name)` reports the steps an actual run recorded.
|
|
41
56
|
steps: [],
|
|
57
|
+
idempotent: typeof handle.idempotencyKeyFor === 'function',
|
|
42
58
|
};
|
|
43
59
|
}
|
|
44
60
|
|
package/src/driver-memory.ts
CHANGED
|
@@ -38,7 +38,18 @@ export interface MemoryDriverOptions {
|
|
|
38
38
|
|
|
39
39
|
const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
40
40
|
|
|
41
|
-
|
|
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 `
|
|
50
|
-
// namespace let two unrelated jobs that derived the same natural key dedupe against
|
|
51
|
-
// other: the second enqueue returned the first's id and its work never ran.
|
|
52
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
package/src/driver-nats.ts
CHANGED
|
@@ -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
|
|
20
|
-
//
|
|
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
|
-
|
|
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 });
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -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.
|
|
49
|
-
--
|
|
50
|
-
--
|
|
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
|
-
|
|
54
|
-
|
|
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
|
package/src/driver-pg-rows.ts
CHANGED
|
@@ -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
|
|
7
|
-
import type
|
|
8
|
-
import
|
|
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:
|
|
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:
|
|
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:
|
|
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),
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
275
|
-
//
|
|
276
|
-
//
|
|
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
|
|
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
|
-
|
|
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-redis.ts
CHANGED
|
@@ -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
|
|
23
|
-
//
|
|
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
|
-
|
|
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 });
|