@ultimat3/jobs 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +165 -124
- package/README.md +360 -21
- package/package.json +6 -6
- package/src/backfill-ledger.ts +24 -2
- package/src/backfill-pass.ts +22 -5
- package/src/claim-exhausted.ts +76 -0
- package/src/concurrency.ts +144 -0
- package/src/counters.ts +105 -0
- package/src/describe.ts +29 -0
- package/src/driver-memory-operator.ts +319 -0
- package/src/driver-memory.ts +146 -59
- package/src/driver-nats.ts +7 -4
- package/src/driver-pg-ddl.ts +71 -0
- package/src/driver-pg-jobs-sql.ts +96 -25
- package/src/driver-pg-operator-sql.ts +323 -0
- package/src/driver-pg-operator.ts +316 -0
- package/src/driver-pg-outbox-sql.ts +110 -0
- package/src/driver-pg-rows.ts +34 -3
- package/src/driver-pg-settle-sql.ts +71 -0
- package/src/driver-pg-sql.ts +171 -151
- package/src/driver-pg-wake-sql.ts +76 -0
- package/src/driver-pg.ts +150 -105
- package/src/driver-redis.ts +7 -4
- package/src/driver.ts +133 -35
- package/src/enqueue-signal.ts +50 -0
- package/src/errors-concurrency.ts +91 -0
- package/src/errors-operator.ts +102 -0
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +18 -30
- package/src/events-pg.ts +45 -45
- package/src/events.ts +38 -2
- package/src/execute.ts +161 -81
- package/src/export.ts +23 -4
- package/src/heartbeat.ts +9 -3
- package/src/idle-backoff.ts +74 -0
- package/src/index.ts +115 -16
- package/src/inspect-operator.ts +49 -0
- package/src/inspect.ts +46 -5
- package/src/introspection.ts +274 -0
- package/src/job.ts +83 -21
- package/src/leases.ts +49 -9
- package/src/limits.ts +33 -7
- package/src/metrics.ts +6 -1
- package/src/outbox-pg.ts +4 -2
- package/src/outbox-relay.ts +114 -39
- package/src/outbox.ts +59 -8
- package/src/progress.ts +78 -0
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +219 -0
- package/src/redact-input.ts +26 -0
- package/src/renewal-timer.ts +28 -10
- package/src/retry-classification.ts +13 -1
- package/src/retry.ts +10 -2
- package/src/run-deadline.ts +57 -0
- package/src/scheduler-leader.ts +26 -0
- package/src/scheduler-occurrences.ts +76 -0
- package/src/scheduler-pg.ts +58 -4
- package/src/scheduler-state.ts +73 -0
- package/src/scheduler.ts +150 -111
- package/src/settled.ts +161 -0
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +55 -81
- package/src/worker-admit.ts +167 -0
- package/src/worker-fleet-slots.ts +157 -54
- package/src/worker-hand-back.ts +6 -1
- package/src/worker-key-busy.ts +107 -0
- package/src/worker-loop.ts +169 -0
- package/src/worker-registry.ts +118 -0
- package/src/worker-run.ts +9 -0
- package/src/worker-types.ts +23 -0
- package/src/worker.ts +142 -130
package/CLAUDE.md
CHANGED
|
@@ -8,7 +8,8 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
8
8
|
`expectedQueryLoop` ONLY: `steps.ts` declares the per-step write one-per-step to the N+1
|
|
9
9
|
detector there, and no client is ever taken from it. Never `http`, `render`, `ui`.
|
|
10
10
|
- Consumers: `action` (`<job>.enqueue`, via the ambient jobs facade), `cli`, `mcp`, `admin`.
|
|
11
|
-
- External deps: none. Postgres access goes through the injected `PgExecutor
|
|
11
|
+
- External deps: none. Postgres access goes through the injected `PgExecutor` (`@ultimat3/core`'s,
|
|
12
|
+
re-exported from `index.ts`).
|
|
12
13
|
|
|
13
14
|
## Rules — registration and declarations
|
|
14
15
|
|
|
@@ -29,7 +30,7 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
29
30
|
- **JOBS DECLARE THEIR TENANT, and `executeJob` INSTALLS the context — do not re-litigate.**
|
|
30
31
|
`tenant` is REQUIRED (`(input) => input.orgId` or `'none'`; `X_JOB_TENANT_REQUIRED` backstop).
|
|
31
32
|
`executeJob` puts the derived org on the ctx's actor (`jobRunActor`, org only) and runs the body in
|
|
32
|
-
`runWithContext`; `'none'` STRIPS the org (fail-closed).
|
|
33
|
+
`runWithContext`; `'none'` STRIPS the org (fail-closed).
|
|
33
34
|
`tenantFor` is a METHOD on `JobHandle` (variance). The queue row's `tenantId` stays the ENQUEUER's
|
|
34
35
|
(the limiter's bucket). `tenancy-cross-surface.test.ts` asserts HTTP and job verdicts are EQUAL.
|
|
35
36
|
- **`enqueuedBy` is ATTRIBUTION, never authority** — do not re-litigate. A job acting FOR a user takes
|
|
@@ -43,48 +44,52 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
43
44
|
|
|
44
45
|
## Rules — the driver and SQL
|
|
45
46
|
|
|
46
|
-
- **`SQL_JOBS_TABLE` is the ONE install point** for every durable table (`x_jobs`, `
|
|
47
|
-
`
|
|
48
|
-
`
|
|
49
|
-
carry NO apostrophes and NO semicolons (`dev-queue.ts` splits on `;`; `driver-pg-sql.test.ts` checks
|
|
47
|
+
- **`SQL_JOBS_TABLE` is the ONE install point** for every durable table (`x_jobs`, `x_job_*`, `x_backfills`,
|
|
48
|
+
`x_outbox`, `x_scheduler_*`). A NEW table is also a name in
|
|
49
|
+
`packages/cli/src/framework-schema.ts`. A shipped table grows by `alter table ... add column if
|
|
50
|
+
not exists`. Its comments carry NO apostrophes and NO semicolons (`dev-queue.ts` splits on `;`; `driver-pg-sql.test.ts` checks
|
|
50
51
|
quote parity).
|
|
51
52
|
- **`claim({ queues: [] })` is REFUSED by every driver** (`assertClaimQueues`,
|
|
52
|
-
`X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
`heartbeat` answers a boolean, read as `held === false` (never `!held`).
|
|
56
|
-
- **A driver's semantics are pinned
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
`X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's reads are `async` so a refusal rejects on both.
|
|
54
|
+
- **`ack`, `nack`, `heartbeat` and `recordProgress` are FENCED on `state = 'running'` AND the
|
|
55
|
+
CLAIM** — `{ workerId, claim }`, `claimOf(claimed)`; `x_jobs.claims` is moved by the claim and
|
|
56
|
+
never reset. `heartbeat` answers a boolean, read as `held === false` (never `!held`).
|
|
57
|
+
- **A driver's semantics are pinned beside the pg statement** (`driver-parity*.test.ts`);
|
|
58
|
+
`driver-*-fixture.ts` scenarios run on memory AND Postgres.
|
|
59
|
+
- **The claim BURIES a lease that lapsed on the final attempt** (`attempt >= max_attempts`): `dead`
|
|
60
|
+
in `SQL_CLAIM` — `failed` for a name in `ClaimOptions.dropExhausted` (`retry.deadLetter: false`)
|
|
61
|
+
— counted, never returned; the worker's round hears `onExhausted` and calls `announceExhausted`.
|
|
62
|
+
- **`cancel` is fenced on `LIVE_STATES`.** **`requeue`** refuses (`X_JOB_NOT_FOUND`, not requeueable,
|
|
63
|
+
duplicate), then ONE statement. **`enqueue` inserts twice at most.** **`BulkResult.remaining` is
|
|
64
|
+
what a second call would move.**
|
|
59
65
|
- **No read returns a WHOLE row** — `driver-pg-sql.test.ts` scans every production file here
|
|
60
66
|
(discovered, comments stripped) for `select *`/`returning *`.
|
|
61
67
|
- Drivers implement the six `JobDriver` methods plus optional `introspect`, `backfills`, `leases`. New
|
|
62
68
|
capabilities go behind the interface. `inspect.ts` returns plain JSON for CLI, `/_x` and MCP.
|
|
63
|
-
- Step results are persisted BEFORE the step returns. All time is epoch ms
|
|
64
|
-
(`nowMs()` in `clock.ts`).
|
|
69
|
+
- Step results are persisted BEFORE the step returns. All time is epoch ms (`nowMs()`, `clock.ts`).
|
|
65
70
|
- **`createPgLeader` is correct only on a DEDICATED connection**; boot uses `createPgLeaseLeader`.
|
|
66
|
-
- `driver-redis.ts` / `driver-nats.ts` are honest `X_NOT_IMPLEMENTED` stubs.
|
|
67
71
|
|
|
68
72
|
## Rules — numbers and limits
|
|
69
73
|
|
|
70
74
|
- **Every numeric knob is refused when not FINITE** — core's `finiteOption()` (a bound) and
|
|
71
75
|
`finiteCount()` (a count, caller's minimum); `worker-options.ts` is where `createWorker` reads them.
|
|
72
|
-
**`bun run finite-bounds` is a floor, never proof**: `createLimiter`'s five numbers
|
|
73
|
-
|
|
74
|
-
STOP). **A row count is `finiteCount` (min 0)** for driver parity (`list`, `deadLetters`, the
|
|
75
|
-
backfill ledger's `list`, `assertClaimBounds`).
|
|
76
|
+
**`bun run finite-bounds` is a floor, never proof**: `createLimiter`'s five numbers are screened
|
|
77
|
+
with min 0 (zero is a HARD STOP). **A row count is `finiteCount` (min 0)**; `retry.attempts` is one with min 1.
|
|
76
78
|
- **`WorkerOptions.concurrency` is read by OWN key** — a queue named `constructor`
|
|
77
79
|
(`worker-slots.test.ts`).
|
|
78
80
|
- **`job.concurrency` is enforced by `JobDriver.leases`** (one row per held slot in `x_job_leases`);
|
|
79
81
|
`limits.ts` is the per-process fast path. No lease store + a declared `concurrency` makes `start()`
|
|
80
82
|
throw `X_JOB_CONCURRENCY_UNENFORCEABLE`.
|
|
81
|
-
- **`
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
- **`concurrency` is resolved ONCE** (`concurrency.ts`): a number, or `{ key, limit, whenBusy }` per
|
|
84
|
+
key. The handle carries `concurrency` (the limit), `whenBusy` (set exactly when keyed) and
|
|
85
|
+
`concurrencyKeyFor()` — a METHOD. Lease key `job-key:<encoded name>:<key>`; plain `job:<name>`.
|
|
86
|
+
A bad cap is `X_JOB_DECLARATION_INVALID`.
|
|
87
|
+
- **`whenBusy: 'fail'` refuses only on EVIDENCE another run holds the key** (`LeaseStore.holders`,
|
|
88
|
+
required): a run meets its own leftover slot. The refusal is `nack({ fail, countsAsAttempt:
|
|
89
|
+
false })` → `failed`, outcome `refused` (`worker-key-busy.ts`), never `executeJob`.
|
|
90
|
+
- **A key underivable at claim is `SlotGrant` `undecidable`** → `executeJob({ refusal })`: the
|
|
91
|
+
ATTEMPT fails, never the round.
|
|
92
|
+
- **`finalAttempt` is `isFinalAttempt(handle.retry, attempt)`**, REQUIRED on `JobRunArgs`.
|
|
88
93
|
|
|
89
94
|
## Rules — the worker and the scheduler
|
|
90
95
|
|
|
@@ -105,36 +110,32 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
105
110
|
letter), read off the SIGNAL. `WorkerStats.interrupted`; `worker-drain-signal.test.ts`.
|
|
106
111
|
- **The drain waits out the claim round it races** — `tick()` registers its round in `rounds`
|
|
107
112
|
synchronously with its guard.
|
|
108
|
-
- **A fleet slot is taken INSIDE a `try`, released AWAITED, and
|
|
109
|
-
run
|
|
113
|
+
- **A fleet slot is taken INSIDE a `try`, released AWAITED, and HELD, not owned**: `false`, or a TTL
|
|
114
|
+
with no renewal landing, CANCELS the run (`X_JOB_SLOT_LOST`); held per job id as a LIST.
|
|
110
115
|
- **The run's signal is a controller this worker owns** (`run-signal.ts`), never `AbortSignal.any`.
|
|
111
|
-
- **A lease is HELD, not owned** (`heartbeat.ts`): one failed renewal
|
|
112
|
-
|
|
113
|
-
process's clock, asked both sides of the call.
|
|
116
|
+
- **A lease is HELD, not owned** (`heartbeat.ts`): one failed renewal warns; a whole window without
|
|
117
|
+
one landing is `jobs.lease.lost`, on this process's clock, asked both sides of the call.
|
|
114
118
|
- **A renewal is decided against `stopped()`** (`renewal-timer.ts`, re-read after every await); the
|
|
115
|
-
interval is `unref`ed.
|
|
119
|
+
interval is `unref`ed; every renewal is armed through `WorkerOptions.schedule`.
|
|
116
120
|
- **Settlement is not part of the retry decision**: `driver.ack` sits after the `try`.
|
|
117
|
-
- **The retry decision reads the ERROR** (`retry-classification.ts
|
|
118
|
-
`
|
|
119
|
-
alone** — core's `declaredErrorRetry(code)`. `retry-after` reuses the nack delay
|
|
120
|
-
(`meta.retryAfterSeconds`, clamped). The verdict is published (`stop`, `stopReason`,
|
|
121
|
-
`recordedFailure`).
|
|
122
|
-
- **The backoff arithmetic is core's** (`backoffDelay` via `backoffDelayMs`), `jitter: true` = EQUAL.
|
|
123
|
-
`RetryPolicy`, `DEFAULT_RETRY`, `retrySchedule()` unchanged; `BackoffStrategy` aliases core's
|
|
124
|
-
`BackoffCurve`. `retry-core-parity.test.ts`. **`classifyThrown` / `statedDelayMs` are core's,
|
|
125
|
-
re-exported** (pinned by identity).
|
|
121
|
+
- **The retry decision reads the ERROR, on core's backoff curve** (`retry-classification.ts`,
|
|
122
|
+
`backoffDelayMs`): `docs/history/jobs.md`, "Moved 2026-10-01 — retry and bounds".
|
|
126
123
|
- **The claim loop re-arms on the PASS, never the jobs**; `tick()` resolves with that pass's executions.
|
|
127
124
|
- **A deadline CANCELS, then fails the attempt**, never the reverse (`raceTimeout`, `withStepTimeout`);
|
|
128
125
|
the attempt is cancelled in `executeJob`'s `finally`.
|
|
129
|
-
- **A cancelled runner writes NOTHING** — `put()` in `steps.ts`
|
|
130
|
-
`
|
|
126
|
+
- **A cancelled runner writes NOTHING** — `put()` in `steps.ts` (`X_ABORTED`), and the STORE fences
|
|
127
|
+
the claim (`StepFence`, `SQL_STEP_PUT` as `SQL_ACK`: `X_JOB_LEASE_LOST`). `executeJob` passes it.
|
|
131
128
|
- **`traceparent` is stamped at ENQUEUE time, in `outbox.ts`**, and an empty `spanId` sends none.
|
|
132
129
|
- **The scheduler runs one dispatch round at a time**; every other `tick()` joins it; `stop()` waits it
|
|
133
130
|
out before `leader.release()`. Same two-hook rule; an ABANDONED round does not release
|
|
134
|
-
(`jobs.scheduler.drain-abandoned`).
|
|
135
|
-
|
|
136
|
-
- **`run-once` fires ONE catch-up
|
|
137
|
-
|
|
131
|
+
(`jobs.scheduler.drain-abandoned`). `stillLeading()` is asked every round and before every task,
|
|
132
|
+
and answers from memory inside `renewEveryMs`.
|
|
133
|
+
- **`run-once` fires ONE catch-up**: the watermark rides the fire (`ScheduledFire.watermarkMs`,
|
|
134
|
+
`SQL_SCHEDULER_FIRE` `$4`), never a `markFired` behind it; `skip` fires the true latest missed
|
|
135
|
+
occurrence (`latestOccurrenceBy`, bisection).
|
|
136
|
+
- **A lease for a run that never STARTED is `abandon()`ed, never `release()`d** — the rate stamp
|
|
137
|
+
goes with it (`worker-admit.ts`). **A failed pass re-arms at the floor** (`worker-loop.ts`
|
|
138
|
+
catch); `timers` is the test seam.
|
|
138
139
|
- **Every timer body catches before it finalises** (`void work().catch(log).finally(...)`).
|
|
139
140
|
- **Suspension is control flow, and a SHED is not a suspension**: `StepSuspension` →
|
|
140
141
|
`nack({ countsAsAttempt: false, park: true })`; a shed is `countsAsAttempt: false` without `park`,
|
|
@@ -147,63 +148,91 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
147
148
|
- **`x jobs cancel` binds to `cancelJob(driver, id, reason?)`, which refuses** a finished job or a
|
|
148
149
|
driver with no `cancel` (`X_JOB_NOT_CANCELLABLE`).
|
|
149
150
|
|
|
151
|
+
## Rules — the operator surface
|
|
152
|
+
|
|
153
|
+
- **Every operator capability is a member of `JobIntrospection`** (`introspection.ts`), implemented
|
|
154
|
+
by BOTH drivers, bounds as constants beside it; `operator-surface*-fixture.ts` is their parity
|
|
155
|
+
suite (memory + real pg).
|
|
156
|
+
- **A settle answers whether it landed**; a miss is `jobs.settle.unowned`, not a throw.
|
|
157
|
+
- **The counter moves in the SETTLING statement** (`driver-pg-settle-sql.ts`): one-minute bucket per
|
|
158
|
+
job name; `nackOutcome()` decides what a nack adds (a shed, a suspension, a drain: nothing), and
|
|
159
|
+
`ack({ counted: false })` — `x jobs drain` — adds nothing. The scheduler leader folds tiers
|
|
160
|
+
(`rollupCounters`), at most once a bucket.
|
|
161
|
+
- **`list()` is KEYSET** (`after: jobCursor(lastRow)`, newest first by `(created_at, id)`); the pg
|
|
162
|
+
seek reads the cursor row's own `created_at` — the cursor's ms is rounded. A page past
|
|
163
|
+
`MAX_JOB_PAGE` or a foreign cursor is `X_JOB_PAGE_INVALID`, never a bare invariant.
|
|
164
|
+
- **`taskFires()` is the last occurrence that DISPATCHED**, never the watermark (arming and skipping
|
|
165
|
+
move that): `SQL_SCHEDULER_FIRE` writes it, `fireThroughDriver` calls `recordTaskFire`.
|
|
166
|
+
- **A pause is a ROW the claim reads** (`x_job_pauses`, `not exists` in `SQL_CLAIM`), never a column
|
|
167
|
+
on `x_jobs`. A paused TASK keeps its watermark, so resume is its own `catchUp`.
|
|
168
|
+
- **An occurrence fires in ONE statement** (`SchedulerState.fire`, `SQL_SCHEDULER_FIRE`): the
|
|
169
|
+
watermark is the fence, jobs insert only `where exists (select 1 from moved)`.
|
|
170
|
+
- **`progress()` is throttled in `progress.ts`** and flushed before every settle. **`onSettled`
|
|
171
|
+
(`settled.ts`) is the ONE ending hook** — `completed`, `dead-lettered`, `dropped`, `refused` —
|
|
172
|
+
AFTER a settle that LANDED, in the body's tenant scope, `ON_SETTLED_ATTEMPTS` tries, never
|
|
173
|
+
rethrown, at most once across a crash. Never for a cancel, a retry or a suspension.
|
|
174
|
+
- **Failed for good + `deadLetter: false` is DROPPED**: `nack({ fail })`, outcome `dropped`.
|
|
175
|
+
- **Worker registry rows expire** (`worker-registry.ts`, TTL = visibility timeout); `stop()` forgets.
|
|
176
|
+
|
|
177
|
+
## Rules — idle cost and the wake
|
|
178
|
+
|
|
179
|
+
Long form: `docs/history/jobs.md`, "the cross-process wake".
|
|
180
|
+
|
|
181
|
+
- **An idle round touches no store.** The scheduler keeps each watermark and its next occurrence in
|
|
182
|
+
memory while it leads (`known`), trusts a lease for `LeaderElection.renewEveryMs`, reads the pause
|
|
183
|
+
table only when a task is DUE, and folds counters every `COUNTER_ROLLUP_INTERVAL_MS`. What makes
|
|
184
|
+
the trust safe is the fire statement's watermark fence, never the lease.
|
|
185
|
+
- **The worker and the relay back off** (`idle-backoff.ts`): floor → doubling → the ceiling, 2 s, or
|
|
186
|
+
5 s while `wakeIsLive()`. An idle worker pass is ONE claim over every queue with a free slot.
|
|
187
|
+
- **The poll is the guarantee, the wake an optimisation.** `startQueueWake` holds one `LISTEN`
|
|
188
|
+
session and raises `enqueue-signal.ts`'s two signals; it is live only once a probe has crossed
|
|
189
|
+
BOTH channels, and un-proven on every re-dial — a transaction-pooling proxy resolves a `LISTEN`
|
|
190
|
+
and delivers nothing.
|
|
191
|
+
- **A statement decides whether it notifies** (`driver-pg-wake-sql.ts`): once per queue per
|
|
192
|
+
`WAKE_SLOT_MS` for an enqueue, never behind an unclaimed row for a stage. Every notifying commit
|
|
193
|
+
serialises behind ONE Postgres lock: never notify unconditionally. Payload: the queue name.
|
|
194
|
+
- **A wake resets the backoff**: the floor passes after it find the row a slot silenced.
|
|
195
|
+
- **The claim loop is ONE chain** (`worker-loop.ts`): a wake on a pass in flight sets `again`; a
|
|
196
|
+
freed slot and a due retry (`JobExecution.resumeAt`) `kick` one pass, no reset.
|
|
197
|
+
- **`idle-cost.test.ts` / `queue-wake.test.ts` count statements per idle minute** — scheduler 6,
|
|
198
|
+
worker and relay 30 with no wake, 12 with. Raising one is a regression.
|
|
199
|
+
- **The memory driver stores a payload's JSON form**, as pg binds it (`driver-settle-parity.test.ts`).
|
|
200
|
+
- **The event bus has ONE clock**: `createPgEventBus` takes no `clock`; `EventLookup.now()` is
|
|
201
|
+
REQUIRED, stamps a NEW wait, and calls the TIMEOUT (asked once the runner thinks time is up).
|
|
202
|
+
`purgeExpired()` is awaited and counted; `eventsPurgeTarget(bus)` is its `PurgeTarget`.
|
|
203
|
+
|
|
150
204
|
## Rules — the outbox
|
|
151
205
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
- **The
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
`markPublished` also requires `published_at is null`. A token-less call is unfenced in memory and
|
|
159
|
-
fenced on this relay's id in pg (`outbox-pg.ts` says so). `id` (UUIDv7) is the tiebreak.
|
|
160
|
-
- **`claimLeaseMs` is normalised in ONE place** (`outbox-lease.ts`, `resolveClaimLeaseMs`,
|
|
161
|
-
`X_INVARIANT` at construction).
|
|
206
|
+
Long form: `docs/history/jobs.md`, "Moved 2026-10-01 — the outbox".
|
|
207
|
+
|
|
208
|
+
- **The relay drains in two phases**; `relay.stop()` JOINS the pass in flight, under a `DrainBudget`
|
|
209
|
+
a later shutdown binds LATE (the worker's shape); its timer is `unref`ed.
|
|
210
|
+
- **The claim is a LEASE in one statement**, fenced on every mutation (`and claimed_by = $n`);
|
|
211
|
+
`claimLeaseMs` is normalised in `outbox-lease.ts`. `outbox-claim.test.ts` pins both stores.
|
|
162
212
|
- **The memory outbox store DELETES a published row** (`retained()` is the seam).
|
|
213
|
+
- **A staged row's id IS its job's id, and its `runId` is allocated at stage** (`x_outbox.run_id`),
|
|
214
|
+
so a staged enqueue answers real ids. `SQL_ENQUEUE` inserts nothing under an id that names a
|
|
215
|
+
row — a repeated publish is `deduped`, live or finished (`outbox-run-id.test.ts`).
|
|
163
216
|
|
|
164
217
|
## Rules — factories over `job()`
|
|
165
218
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
-
|
|
179
|
-
|
|
180
|
-
- **The ledger says what RAN, the registry what EXISTS, `backfill-pending.ts` is the diff**;
|
|
181
|
-
`isPendingBackfillState` is shared with `x db backfill --all`.
|
|
182
|
-
- **`environments` is checked in `backfillPass()` (the rail) and `gateBackfill()` (a CLI pre-check);
|
|
183
|
-
`requires` in `gateBackfill()` only; `count` in `backfillPass()` after the last batch**
|
|
184
|
-
(`X_BACKFILL_STALLED`, its result parsed). `gateBackfill()` RETURNS its refusal.
|
|
185
|
-
- **`inspectBackfills()` is the ONE projection of the ledger**, reads no clock, answers `[]` for a
|
|
186
|
-
driver with none. The ledger hangs off `driver.backfills`, optional.
|
|
187
|
-
- **`purge()`** is the one caller of every `purgeExpired()`: `PurgeTarget` is structural, `targets()` a
|
|
188
|
-
thunk, one table per `step.run`, one clock reading for the whole pass, duplicate names refused
|
|
189
|
-
(`X_INVARIANT`). `DEFAULT_PURGE_CRON`; `@ultimat3/cli`'s `dev-purge.ts` schedules it.
|
|
190
|
-
- **`exportRows()`**: one object per PAGE, named by page index (a rerun rewrites the same bytes); the
|
|
191
|
-
interleaving assertion in `export-pass.test.ts` is the memory guard; NO cross-tenant escape; the CSV
|
|
192
|
-
formula guard is on strings only.
|
|
193
|
-
- **`webhook()` delivers ONE event to ONE endpoint** (key `<name>:<endpointId>:<eventId>`); no steps
|
|
194
|
-
(the endpoint carries the secret); the timestamp is SEND time; endpoint headers merge UNDER the
|
|
195
|
-
framework's; `redirect: 'manual'`. `WebhookLedger` is a seam; every attempt is recorded before the
|
|
196
|
-
throw. **The wire format is core's** (`packages/core/src/webhook-signature.ts`), re-exported.
|
|
219
|
+
Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-01 — factories".
|
|
220
|
+
|
|
221
|
+
- **`backfill()`**: a step persists the CURSOR and a count, never the page; step names are positional;
|
|
222
|
+
**`handle` is AT LEAST ONCE** — never invert; a REPLAYED batch writes no ledger row. `tenant: 'none'`
|
|
223
|
+
is the only cross-tenant scope (`backfill-scope.ts`). `x_backfills` is what was SWEPT, keyed by RUN;
|
|
224
|
+
only `completed` blocks. The throttle is spent INSIDE the batch's `step.run`.
|
|
225
|
+
- **The ledger says what RAN, the registry what EXISTS, `backfill-pending.ts` is the diff.**
|
|
226
|
+
`environments` is checked in `backfillPass()` and `gateBackfill()`; `requires` in `gateBackfill()`
|
|
227
|
+
only; `count` after the last batch (`X_BACKFILL_STALLED`). `inspectBackfills()` is the ONE projection.
|
|
228
|
+
- **`purge()`** is the one caller of every `purgeExpired()`: one table per `step.run`, one clock
|
|
229
|
+
reading per pass, duplicate names refused.
|
|
230
|
+
- **`exportRows()`**: one object per PAGE, named by page index; NO cross-tenant escape.
|
|
231
|
+
- **`webhook()`** delivers ONE event to ONE endpoint; no steps; every attempt recorded before the
|
|
232
|
+
throw. **The wire format is core's**, re-exported.
|
|
197
233
|
- A `-fixture.ts` file does not ship; `backfill-pass-fixture.ts` raises a plain `Error` subclass on
|
|
198
234
|
purpose (it stands in for app code).
|
|
199
235
|
|
|
200
|
-
## Known coupling
|
|
201
|
-
|
|
202
|
-
`driver-pg.ts`'s `PgExecutor` is a one-method structural interface (no `@ultimat3/db` dependency).
|
|
203
|
-
`packages/cli/src/dev-queue.ts` wraps a real `@ultimat3/db` client, so queue statements pass
|
|
204
|
-
`@ultimat3/db`'s statement observer with no `{entity, op}` attribution — future work, see
|
|
205
|
-
`packages/db/CLAUDE.md`'s `observe.ts` section.
|
|
206
|
-
|
|
207
236
|
## Files
|
|
208
237
|
|
|
209
238
|
| File | Owns |
|
|
@@ -212,60 +241,72 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
212
241
|
| `tenant.ts` | what tenant a run acts under: `JobTenant`, the declaration's backstop, the actor `executeJob` installs |
|
|
213
242
|
| `backfill.ts` | `backfill()` — a factory over `job()`: the declaration, its checksum and its input |
|
|
214
243
|
| `backfill-pass.ts` | one pass: the batched iteration, its cursor checkpoints and its ledger row |
|
|
215
|
-
| `backfill-scope.ts` | which sweeps run across tenants
|
|
244
|
+
| `backfill-scope.ts` | which sweeps run across tenants (`tenant: 'none'` only) |
|
|
216
245
|
| `backfill-ledger.ts` | `x_backfills` — the contract, `BACKFILL_STATUSES`, the checksum, the verdict, the memory ledger |
|
|
217
246
|
| `backfill-registry.ts` | what was DECLARED: the `origin` stamp, `isBackfill`, `registeredBackfills` |
|
|
218
247
|
| `backfill-gate.ts` | may this sweep run here and now — environment, `requires`, already-applied |
|
|
219
248
|
| `backfill-pending.ts` | declared minus completed, per environment: the alarm `--pending` reads |
|
|
220
249
|
| `backfill-rate.ts` | the `rate` throttle: batches/sec as an interval, and the cancellable wait |
|
|
221
250
|
| `backfill-inspect.ts` | the ledger projected for `x db backfill`, `x jobs`, `/_x` and MCP |
|
|
222
|
-
| `backfill-errors.ts` | the seven `X_BACKFILL_*` classes
|
|
223
|
-
| `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements
|
|
251
|
+
| `backfill-errors.ts` | the seven `X_BACKFILL_*` classes; the codes stay declared in `errors.ts` |
|
|
252
|
+
| `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements; an `action-job` projection is `X_ACTION_JOB_UNBRIDGED` |
|
|
224
253
|
| `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
|
|
225
254
|
| `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
|
|
255
|
+
| `steps-timeout.ts` / `steps-suspension.ts` | a step's ceiling; `StepSuspension` and the timed-out marker |
|
|
226
256
|
| `outbox.ts` | staging in a `Tx`, the store seam, the ambient `JobsFacade` slot |
|
|
227
|
-
| `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks
|
|
257
|
+
| `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks |
|
|
228
258
|
| `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
|
|
229
259
|
| `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
|
|
230
260
|
| `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
|
|
261
|
+
| `concurrency.ts` | `job.concurrency` as declared, resolved once: `KeyedConcurrency`, `WhenBusy`, the declaration and key refusals |
|
|
262
|
+
| `errors-concurrency.ts` | every concurrency refusal's class; codes stay in `errors.ts` |
|
|
263
|
+
| `worker-key-busy.ts` | one run refused by its key: settled `failed`, body never run |
|
|
231
264
|
| `metrics.ts` | `queue_oldest_ready_seconds` and `queue_dead_jobs`, the two alertable gauges |
|
|
232
|
-
| `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark) + `createPgLeaseLeader` |
|
|
265
|
+
| `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark, the atomic fire) + `createPgLeaseLeader` |
|
|
233
266
|
| `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
|
|
234
267
|
| `driver.ts` | `JobDriver` contract + wire records |
|
|
235
|
-
| `driver-pg.ts` | default driver,
|
|
236
|
-
| `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` — the
|
|
237
|
-
| `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and
|
|
268
|
+
| `driver-pg.ts` | default driver, and `createPgLeader` |
|
|
269
|
+
| `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` — the ONE install point. Its comments carry no `;` and no `'` |
|
|
270
|
+
| `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and `JOB_ROW_COLUMNS`; re-exported from `driver-pg-sql.ts` |
|
|
238
271
|
| `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
|
|
239
|
-
| `driver-memory.ts` | `x dev` / tests |
|
|
272
|
+
| `driver-memory.ts` / `claim-exhausted.ts` | `x dev` / tests; a buried row's log line and `onSettled` |
|
|
240
273
|
| `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
|
|
241
274
|
| `retry.ts` | the dead-letter decision, and this package's option names over core's `backoffDelay` — no curve of its own |
|
|
242
|
-
| `retry-classification.ts` | the
|
|
243
|
-
| `execute.ts` | `executeJob` — one claimed job run and settled
|
|
275
|
+
| `retry-classification.ts` | the other half: what the thrown error says, and the stop reason |
|
|
276
|
+
| `execute.ts` / `run-deadline.ts` | `executeJob` — one claimed job run and settled; the run's deadline: cancel, then fail |
|
|
244
277
|
| `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
|
|
245
|
-
| `renewal-timer.ts` | the
|
|
246
|
-
| `worker.ts` | `worker` role, claim
|
|
278
|
+
| `renewal-timer.ts` | the renewal interval and its `stopped()` latch |
|
|
279
|
+
| `worker.ts` | `worker` role, claim round, drain |
|
|
280
|
+
| `worker-admit.ts` | may this claimed job start: the limiter, the fleet slot, and the shed / refusal |
|
|
281
|
+
| `worker-registry.ts` | one worker's registry row: announce, heartbeat, forget |
|
|
247
282
|
| `worker-types.ts` | the worker's public contract: `WorkerOptions`, `WorkerStats`, `Worker` |
|
|
248
|
-
| `drain-wait.ts` | the drain's wait, shared by both roles
|
|
249
|
-
| `worker-run.ts` | one claimed job, wired:
|
|
250
|
-
| `run-signal.ts` | the signal ONE run is cancelled by
|
|
251
|
-
| `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back
|
|
252
|
-
| `purge.ts` | `purge()` — a factory over `job()`: the retention sweep
|
|
283
|
+
| `drain-wait.ts` | the drain's wait, shared by both roles |
|
|
284
|
+
| `worker-run.ts` | one claimed job, wired: heartbeat, slot renewal, run signal, span |
|
|
285
|
+
| `run-signal.ts` | the signal ONE run is cancelled by |
|
|
286
|
+
| `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back — and the boot refusal |
|
|
287
|
+
| `purge.ts` | `purge()` — a factory over `job()`: the retention sweep and its target seam |
|
|
253
288
|
| `task.ts` | the `task()` primitive + registry + the handle's surface + `registerTask` |
|
|
254
289
|
| `scheduler.ts` | `scheduler` role: the dispatch round, catch-up, leader election, the drain |
|
|
255
290
|
| `limits.ts` | per-tenant / per-queue / global concurrency + rate |
|
|
256
291
|
| `events.ts` | stored event bus for `step.waitForEvent` |
|
|
257
292
|
| `inspect.ts` | `--json` introspection |
|
|
293
|
+
| `inspect-operator.ts` | the verbs `x jobs` binds: remove, promote, pause, resume |
|
|
294
|
+
| `introspection.ts` | `JobIntrospection`, every operator type, and the bounds |
|
|
295
|
+
| `counters.ts` | bucket arithmetic, `nackOutcome`, the memory counters |
|
|
296
|
+
| `driver-memory-operator.ts` / `driver-pg-operator.ts` | each driver's operator members |
|
|
297
|
+
| `driver-pg-operator-sql.ts` / `driver-pg-settle-sql.ts` | their statements; `SQL_ACK` / `SQL_NACK` with the counter |
|
|
298
|
+
| `progress.ts` / `settled.ts` / `redact-input.ts` | the progress throttle; the `onSettled` runner; a payload as an operator reads it |
|
|
299
|
+
| `scheduler-state.ts` | `SchedulerState`, `fire`, the memory watermark |
|
|
300
|
+
| `scheduler-leader.ts` / `scheduler-occurrences.ts` | `LeaderElection` + `soleLeader`; which occurrences fall in a window |
|
|
301
|
+
| `idle-backoff.ts` / `enqueue-signal.ts` | the wait both polling loops share; the two wake signals and `wakeIsLive` |
|
|
302
|
+
| `queue-wake.ts` / `driver-pg-wake-sql.ts` | the `LISTEN` session and its proof; which statement notifies |
|
|
303
|
+
| `worker-loop.ts` | the claim loop's timer: wake, `again`, the kicks |
|
|
304
|
+
| `driver-pg-outbox-sql.ts` | the `x_outbox` statements |
|
|
305
|
+
| `errors-operator.ts` / `errors-requeue.ts` | the operator verbs' refusals; `requeue`'s |
|
|
258
306
|
|
|
259
|
-
|
|
260
|
-
`
|
|
261
|
-
`idempotency` and `outbox-atomicity` each prove one guarantee through a REAL worker, and
|
|
262
|
-
`worker-soak.job.test.ts` kills one worker mid-job and asserts exactly-once completion.
|
|
263
|
-
|
|
264
|
-
## Commands
|
|
307
|
+
Which `-fixture.ts` harness each suite shares, and what each `.job.` suite proves:
|
|
308
|
+
`docs/history/jobs.md`, "Moved 2026-10-01 — test harnesses".
|
|
265
309
|
|
|
266
|
-
|
|
267
|
-
bun test packages/jobs
|
|
268
|
-
bun run --filter @ultimat3/jobs typecheck
|
|
269
|
-
```
|
|
310
|
+
Commands: `bun test packages/jobs` · `bun run --filter @ultimat3/jobs typecheck`.
|
|
270
311
|
|
|
271
312
|
Why each rule above is shaped the way it is: [`docs/history/jobs.md`](../../docs/history/jobs.md).
|