@ultimat3/jobs 19.2.0 → 19.3.2

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
@@ -4,7 +4,9 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
4
4
 
5
5
  ## Boundary
6
6
 
7
- - May import: `core`, `schema`, `entity`, `policy`, `cache`, `time`. Never `http`, `render`, `ui`.
7
+ - May import: `core`, `schema`, `entity`, `policy`, `cache`, `time` and `db`, for
8
+ `expectedQueryLoop` ONLY: `steps.ts` declares the per-step write one-per-step to the N+1
9
+ detector there, and no client is ever taken from it. Never `http`, `render`, `ui`.
8
10
  - Consumers: `action` (`<job>.enqueue`, via the ambient jobs facade), `cli`, `mcp`, `admin`.
9
11
  - External deps: none. Postgres access goes through the injected `PgExecutor`.
10
12
 
@@ -265,6 +267,16 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
265
267
  standstill, so start -> stop -> start stacked a second registration retaining a stopped worker's
266
268
  driver, and the next process-wide drain ran all of them. `start()` refuses while draining for the
267
269
  same reason: a claim loop back on a driver the drain is about to close.
270
+ - **The outbox relay drains in the same two phases, `As of 2026-09`.** It registered NO hook: on
271
+ SIGTERM it went on claiming and publishing through every phase of the drain, and the only thing
272
+ that ever stopped it was `RunningRoles.stop()` in `x dev`'s release path — a caller a signal can
273
+ skip. Two consequences, both silent: a lease stamped on rows nothing on this pod will run (stranded
274
+ for the visibility window), and a row past `driver.enqueue` but short of `markPublished` published
275
+ a second time on the next boot, which the idempotency key collapses only while the first job is
276
+ still live. `accept` clears the interval and returns; `close` awaits the pass in flight under
277
+ `settleAllBy(…, reason.deadlineAt)`; both unregisters come back in the teardown's `finally`. The
278
+ poll timer is `unref`ed for `renewal-timer.ts`'s reason — a 200ms interval refed holds the event
279
+ loop open past every phase and makes SIGKILL the exit.
268
280
  - **A claimed job is counted with core's `beginWork()`, so the DRAIN does the waiting**
269
281
  (`As of 2026-08-23`). The wait for in-flight jobs belongs to the phase between `accept` and
270
282
  `inflight`, which exists for exactly this and is where `@ultimat3/http` already puts a request —
@@ -281,6 +293,31 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
281
293
  promises; `jobs.worker.drain-abandoned` names it, with the `configureLifecycle({ deadlineMs })`
282
294
  raise as its fix. **A worker always REACHES `'stopped'`**, which is what makes `stop()`'s
283
295
  `state === 'stopped'` early return an answer rather than a wedge.
296
+ - **SIGTERM reaches the job: the `accept` hook aborts every held run's `ctx.signal`**
297
+ (`As of 2026-09-07`). The worker holds ONE `AbortController` (`drainSignal`), composed into every
298
+ run by `worker-run.ts` as the third source beside the caller's signal and the heartbeat's;
299
+ `stopAccepting(reason)` aborts it with a `JobDrainedError` — core's `X_DRAINING`, naming the
300
+ worker and the signal — and a manual `stop()` passes no reason and aborts nothing, for the same
301
+ line `settleAllBy` draws: a caller that asked wants its work finished. It has to be the `accept`
302
+ hook and not the teardown, because core runs `accept`, then waits out in-flight work (every
303
+ claimed job is `beginWork()`ed) under the same budget, then `close`: told in `close`, a body
304
+ would hear it after the in-flight wait had already spent the whole budget on it. Which is what
305
+ happened until this landed — the drain told nobody, so a body reading `ctx.signal` (the one
306
+ documented way to stop early) ran to the deadline and was abandoned there, indistinguishable
307
+ from one that ignores the signal; ai-maxxing measured it as every Ctrl-C paying the full 25s
308
+ and wrote a process-wide signal of its own to get around it. The controller is replaced with a
309
+ fresh one in the teardown's `finally`: aborted once stays aborted, and a restarted worker would
310
+ otherwise hand every job it claimed a signal born cancelled.
311
+ `executeJob` reads the CODE off the run signal's reason (`drainedBy`) and settles a drained
312
+ attempt as **`interrupted`** — `nack` with `countsAsAttempt: false`, no park, no dead letter,
313
+ `delayMs: 0`, the error still recorded on the row — read off the SIGNAL and not the thrown
314
+ error, because what a body stops WITH is not always the reason (a killed ssh child surfaces as
315
+ the app's own coded error) and an attempt burned per deploy is the "always twice" draining
316
+ exists to prevent, one layer down. Not a `retried`: `attempts: 1` would dead-letter a job the
317
+ process, not the job, cut short, and `jobs_total{outcome="failed"}` would spike on every
318
+ rollout. The first reason on a controller wins, so a timeout that fired before the drain still
319
+ reports as a timeout. `WorkerStats.interrupted` counts them; `jobs.worker.drain-signalled` and
320
+ `jobs.attempt.interrupted` are the two lines. `worker-drain-signal.test.ts` holds all of it.
284
321
  - **One teardown, joined.** `stop()` shares the in-flight teardown promise, so a SIGTERM landing
285
322
  on a manual stop waits out the same in-flight jobs instead of closing the driver underneath
286
323
  it. The promise is cleared as it settles, so a worker that started again tears down again
@@ -905,7 +942,8 @@ picture from the other side.
905
942
  | `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` |
906
943
  | `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
907
944
  | `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
908
- | `outbox.ts` | staging in a `Tx`, the relay, the ambient `JobsFacade` slot |
945
+ | `outbox.ts` | staging in a `Tx`, the store seam, the ambient `JobsFacade` slot |
946
+ | `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks. Split off at `outbox.ts`'s 500-line ceiling |
909
947
  | `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
910
948
  | `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
911
949
  | `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
@@ -924,7 +962,8 @@ picture from the other side.
924
962
  | `execute.ts` | `executeJob` — one claimed job run and settled, and the run's deadline/cancel |
925
963
  | `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
926
964
  | `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
927
- | `worker.ts` | `worker` role, claim loop, drain |
965
+ | `worker.ts` | `worker` role, claim loop, drain — and the one `AbortController` SIGTERM reaches every held run through |
966
+ | `worker-types.ts` | the worker's public contract: `WorkerOptions`, `WorkerStats`, `Worker` |
928
967
  | `drain-wait.ts` | the drain's wait, shared by both roles: everything a teardown holds, settled — or abandoned at the budget the `close` hook was handed |
929
968
  | `worker-run.ts` | one claimed job, wired: its heartbeat, its slot renewal, its run signal and its span, started together and handed back in one `finally` |
930
969
  | `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
package/README.md CHANGED
@@ -550,6 +550,24 @@ Nothing can kill a body that ignores the signal, so the durable state is fenced:
550
550
  cancel every step write is refused with `X_ABORTED`, and a run that finishes anyway is logged
551
551
  as `jobs.timeout.abandoned` — the one way to find a handler that never reads `ctx.signal`.
552
552
 
553
+ **SIGTERM fires the same signal** (`As of 2026-09-07`). The moment the drain begins — its `accept`
554
+ phase, before the in-flight wait starts spending the budget — the worker aborts `ctx.signal` on
555
+ every job it holds with `X_DRAINING`, naming the worker and the signal. A body that unwinds is
556
+ **interrupted**, not failed: the job goes straight back to the ready bucket with the attempt
557
+ uncounted, so `attempts: 1` survives a deploy and the worker replacing this one claims it at
558
+ once. Whatever the body stopped with — the reason back from `fetch`, `throwIfAborted`'s
559
+ `X_ABORTED`, an app's own error for a child the shutdown killed — the verdict is read off the
560
+ signal, not the error. A body that ignores the signal is waited on to the deadline and abandoned
561
+ there, as before. A manual `stop()` aborts nothing: it waits for the work it holds.
562
+
563
+ | The attempt ended by | `JobOutcome` | attempt counted | `jobs_total` |
564
+ |---|---|---|---|
565
+ | the body returning | `completed` | — | `ok` |
566
+ | `step.sleep` / `step.waitForEvent` | `suspended` | no | not counted |
567
+ | the body throwing, attempts left | `retried` | yes | `failed` |
568
+ | the body throwing, none left or `terminal` | `dead-lettered` | yes | `dead` |
569
+ | the worker's drain (`X_DRAINING` on `ctx.signal`) | `interrupted` | **no** | not counted |
570
+
553
571
  Three ceilings, declared on the job and nowhere else (`As of 2026-08` — `stepTimeout` and
554
572
  `eventPoll` had been implemented in the step runner since 1.0 with no declaration able to reach
555
573
  them, so no `job()` could ask for either):
@@ -603,6 +621,11 @@ things have to be true in a process:
603
621
  | the facade is installed | `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` |
604
622
  | the relay is running | `createOutboxRelay({ store, driver }).start()` |
605
623
 
624
+ `start()` registers the same two shutdown hooks `createWorker` does — `accept` stops polling
625
+ (the worker's also aborts every held job's `ctx.signal`), `close` waits out the pass in flight
626
+ under the drain's deadline — and `stop()` hands both back.
627
+ `drainOnShutdown: false` opts out, for a caller that drives its own teardown.
628
+
606
629
  with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
607
630
  transactional: `stage()` runs on the CALLER'S connection, never the pool. With nothing installed,
608
631
  `jobsFacade()` answers a fallback whose `currentTx` is `() => undefined` and every enqueue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "19.2.0",
3
+ "version": "19.3.2",
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,10 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "19.2.0",
36
- "@ultimat3/entity": "19.2.0",
37
- "@ultimat3/schema": "19.2.0",
38
- "@ultimat3/time": "19.2.0"
35
+ "@ultimat3/core": "19.3.2",
36
+ "@ultimat3/db": "19.3.2",
37
+ "@ultimat3/entity": "19.3.2",
38
+ "@ultimat3/schema": "19.3.2",
39
+ "@ultimat3/time": "19.3.2"
39
40
  }
40
41
  }
package/src/drain-wait.ts CHANGED
@@ -6,45 +6,95 @@
6
6
  import { systemClock } from '@ultimat3/core';
7
7
 
8
8
  /**
9
- * Everything in `pending`, settled or abandoned once `deadlineAt` (real monotonic ms, the clock
10
- * `ShutdownReason.deadlineAt` is measured on) has passed. Answers `true` when everything settled.
9
+ * The deadline a teardown waits under, and it is bound LATE. `undefined` while the stop is manual;
10
+ * a real monotonic instant (the clock `ShutdownReason.deadlineAt` is measured on) the moment a
11
+ * shutdown lands — before the teardown starts, or in the middle of it. A wait already in progress
12
+ * adopts it, which is the case a plain number could not express: `worker.stop('deploy')` starts a
13
+ * teardown with no budget, SIGTERM arrives, core's `close` hook JOINS the memoised teardown — and
14
+ * the number that teardown was started with is the number it kept. Core abandoned the hook at the
15
+ * deadline and moved on; the worker sat on a body that ignores `ctx.signal` with its driver open
16
+ * and `stopping` never settling, exactly the wedge the bound was written to end.
11
17
  *
12
- * `deadlineAt` is `undefined` for a MANUAL `stop()`, which waits as long as its work takes: a
13
- * caller that asked a role to stop has no budget to spend, and closing the queue under a live job
14
- * — or handing the lease back under a live dispatch — is exactly what draining exists to prevent. The bound belongs to the SIGTERM path, where the
15
- * budget is real and a handler that ignores `ctx.signal` would otherwise hold the teardown — and
16
- * with it the memoized `stopping` promise every later `stop()` joins — open forever.
18
+ * The EARLIEST deadline wins: a second bind can only tighten, never extend, because the budget it
19
+ * comes from is one process-wide grace period and not a per-caller allowance.
20
+ */
21
+ export interface DrainBudget {
22
+ /** The deadline in force `undefined` until a shutdown binds one. */
23
+ readonly deadlineAt: number | undefined;
24
+ /** Bind a deadline, or tighten the one held. Every wait in progress hears it at once. */
25
+ bind(deadlineAt: number): void;
26
+ /**
27
+ * Hear the deadline: now, when one is already bound, and again each time it tightens. Answers
28
+ * the unsubscribe, which a settled wait calls so a budget outlives none of its waiters.
29
+ */
30
+ watch(listener: (deadlineAt: number) => void): () => void;
31
+ }
32
+
33
+ export function createDrainBudget(deadlineAt?: number): DrainBudget {
34
+ let bound = deadlineAt;
35
+ const listeners = new Set<(deadlineAt: number) => void>();
36
+ return {
37
+ get deadlineAt() {
38
+ return bound;
39
+ },
40
+ bind(at) {
41
+ if (bound !== undefined && at >= bound) return;
42
+ bound = at;
43
+ for (const listener of listeners) listener(at);
44
+ },
45
+ watch(listener) {
46
+ listeners.add(listener);
47
+ if (bound !== undefined) listener(bound);
48
+ return () => {
49
+ listeners.delete(listener);
50
+ };
51
+ },
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Everything in `pending`, settled — or abandoned once the deadline (real monotonic ms) has
57
+ * passed. Answers `true` when everything settled.
58
+ *
59
+ * The deadline is a number for a role whose teardown cannot be joined mid-flight, and a
60
+ * `DrainBudget` for one that can — the worker, whose manual `stop()` a later SIGTERM joins.
61
+ * `undefined` is a MANUAL `stop()`, which waits as long as its work takes: a caller that asked a
62
+ * role to stop has no budget to spend, and closing the queue under a live job — or handing the
63
+ * lease back under a live dispatch — is exactly what draining exists to prevent. The bound belongs
64
+ * to the SIGTERM path, where the budget is real and a handler that ignores `ctx.signal` would
65
+ * otherwise hold the teardown — and with it the memoized `stopping` promise every later `stop()`
66
+ * joins — open forever.
17
67
  *
18
68
  * `allSettled`, so work that rejected is work that finished: each caller observes its own failures
19
69
  * already, and a teardown that rethrew here would skip the close behind it.
20
70
  */
21
71
  export async function settleAllBy(
22
72
  pending: readonly Promise<unknown>[],
23
- deadlineAt: number | undefined,
73
+ deadline: DrainBudget | number | undefined,
24
74
  ): Promise<boolean> {
25
75
  if (pending.length === 0) return true;
26
76
  const settled = Promise.allSettled(pending);
27
- if (deadlineAt === undefined) {
28
- await settled;
29
- return true;
30
- }
31
- const remainingMs = Math.max(0, deadlineAt - systemClock.monotonic());
77
+ const budget = typeof deadline === 'object' ? deadline : createDrainBudget(deadline);
32
78
  return await new Promise<boolean>((resolve) => {
33
79
  let decided = false;
34
- const timer = setTimeout(() => {
35
- if (decided) return;
36
- decided = true;
37
- resolve(false);
38
- }, remainingMs);
39
- // Never the thing keeping a drained process alive — the rule `lifecycle-deadline.ts` states
40
- // for its own timer. A spent budget still gives the already-settled case its turn, because a
41
- // resolved promise settles on a microtask and this timer on a macrotask.
42
- timer.unref?.();
43
- void settled.then(() => {
80
+ let timer: ReturnType<typeof setTimeout> | undefined;
81
+ const decide = (drained: boolean): void => {
44
82
  if (decided) return;
45
83
  decided = true;
46
- clearTimeout(timer);
47
- resolve(true);
84
+ unwatch();
85
+ if (timer !== undefined) clearTimeout(timer);
86
+ resolve(drained);
87
+ };
88
+ // Re-armed on every tightening, so a wait that began with no deadline ends at the one a later
89
+ // shutdown bound. Never the thing keeping a drained process alive — the rule
90
+ // `lifecycle-deadline.ts` states for its own timer. A spent budget still gives the
91
+ // already-settled case its turn, because a resolved promise settles on a microtask and this
92
+ // timer on a macrotask.
93
+ const unwatch = budget.watch((deadlineAt) => {
94
+ if (timer !== undefined) clearTimeout(timer);
95
+ timer = setTimeout(() => decide(false), Math.max(0, deadlineAt - systemClock.monotonic()));
96
+ timer.unref?.();
48
97
  });
98
+ void settled.then(() => decide(true));
49
99
  });
50
100
  }
@@ -25,7 +25,7 @@ import { JobDuplicateError } from './errors';
25
25
  import type { LeaseStore } from './leases';
26
26
  import { createMemoryLeaseStore } from './leases';
27
27
  import type { StepStore } from './steps';
28
- import { createMemoryStepStore } from './steps';
28
+ import { createMemoryStepStore } from './steps-memory';
29
29
 
30
30
  export interface MemoryDriverOptions {
31
31
  readonly clock?: Clock;
@@ -146,7 +146,11 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
146
146
  // `state !== 'done'`, mirroring `SQL_CANCEL`: a job that already finished has nothing to
147
147
  // stop, and cancelling it would rewrite a terminal row an operator is reading as success.
148
148
  if (existing === undefined || existing.state === 'done') return Promise.resolve(undefined);
149
- update(jobId, {
149
+ // `settle`, not `update`: a cancellation RELEASES the claim, and `SQL_CANCEL` writes
150
+ // `visible_at = null, claimed_by = null` with the state. Left stamped, a cancelled row named
151
+ // the worker still holding it and carried that attempt's lease deadline — the pair
152
+ // `x jobs show` prints, and the pair the claim scan reads to decide a row was abandoned.
153
+ settle(jobId, {
150
154
  state: 'cancelled',
151
155
  ...(reason === undefined ? {} : { lastError: reason }),
152
156
  });
@@ -161,7 +165,11 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
161
165
  leases,
162
166
  introspect,
163
167
 
164
- enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
168
+ // `async` for the reason `claim`, `list` and `deadLetters` are: `onConflict: 'error'` REJECTS
169
+ // here exactly as the pg driver's does, and a synchronous throw out of a method typed
170
+ // `Promise<…>` is a second answer to one question — caught by different code, and an
171
+ // unhandled exception rather than a settled promise wherever the caller holds the promise.
172
+ async enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
165
173
  const existing = liveByKey(request.name, request.idempotencyKey, request.tenantId);
166
174
  if (existing !== undefined) {
167
175
  if (request.onConflict === 'error') {
@@ -171,7 +179,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
171
179
  existingId: existing.id,
172
180
  });
173
181
  }
174
- return Promise.resolve({ id: existing.id, runId: existing.runId, deduped: true });
182
+ return { id: existing.id, runId: existing.runId, deduped: true };
175
183
  }
176
184
 
177
185
  const at = nowMs(clock);
@@ -194,7 +202,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
194
202
  ...(request.enqueuedBy === undefined ? {} : { enqueuedBy: request.enqueuedBy }),
195
203
  };
196
204
  jobs.set(record.id, record);
197
- return Promise.resolve({ id: record.id, runId: record.runId, deduped: false });
205
+ return { id: record.id, runId: record.runId, deduped: false };
198
206
  },
199
207
 
200
208
  // `async`, so an empty queue list REJECTS here exactly as it does on the pg driver: a
@@ -16,13 +16,22 @@ import type {
16
16
  import { JobsNotImplementedError } from './errors';
17
17
  import type { StepRecord, StepStore } from './steps';
18
18
 
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.
19
+ // Names the seam that actually replaces the stub, and NOTHING ELSE — the two other repairs this
20
+ // line has carried were both unrunnable.
21
+ //
22
+ // NOT `jobs: { driver }` in app.config.ts, which it said until 2026-08-20: the field had no
23
+ // reader anywhere, so that edit repaired nothing and sent the reader back to the same throw. It
24
+ // is deleted now.
25
+ //
26
+ // NOT `x jobs drain --to memory` either, which it said until 2026-09, and that one was a route
27
+ // into data loss: the target is a Map inside the command's own process, so the drain acked every
28
+ // durable row and lost the copy at exit. `x jobs` refuses that value by name now. There is no
29
+ // drain to run in its place, and the reason is in this file — `enqueue` below refuses too, so
30
+ // nothing was ever queued onto this driver and the queue is untouched.
31
+ //
23
32
  // The nats driver lands in v2; there is no flag that turns this one on.
24
33
  const FIX =
25
- 'call setJobDriver(createPgDriver()) at boot instead of this driver, then move what is already queued: x jobs drain --to memory --json';
34
+ 'call setJobDriver(createPgDriver()) at boot instead of this driver; nothing needs moving first, because enqueue here refuses too, so no job was ever written to it';
26
35
 
27
36
  const unavailable = (method: string): never => {
28
37
  throw new JobsNotImplementedError({ feature: `nats jobs driver (${method})`, fix: FIX });
package/src/driver-pg.ts CHANGED
@@ -54,8 +54,10 @@ import type { HeldLease, LeaseStore } from './leases';
54
54
  import type { StepStore } from './steps';
55
55
 
56
56
  /**
57
- * The one thing this driver needs from the DB layer, declared structurally so this package can
58
- * depend on no database package at all.
57
+ * The one thing this driver needs from the DB layer, declared structurally so this package needs
58
+ * no database CLIENT: `@ultimat3/db` is imported for `expectedQueryLoop` — the marker that tells
59
+ * the N+1 detector a step write is one-per-step by design (`steps.ts`) — and never for a
60
+ * connection.
59
61
  *
60
62
  * **Not satisfied by `Bun.sql`** — verified against Bun 1.4.0: `Bun.sql.query` is `undefined`.
61
63
  * `Bun.sql` is a tagged template whose positional form is `unsafe`, so a `{ executor: Bun.sql }`
@@ -19,13 +19,22 @@ import type {
19
19
  import { JobsNotImplementedError } from './errors';
20
20
  import type { StepRecord, StepStore } from './steps';
21
21
 
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.
22
+ // Names the seam that actually replaces the stub, and NOTHING ELSE — the two other repairs this
23
+ // line has carried were both unrunnable.
24
+ //
25
+ // NOT `jobs: { driver }` in app.config.ts, which it said until 2026-08-20: the field had no
26
+ // reader anywhere, so that edit repaired nothing and sent the reader back to the same throw. It
27
+ // is deleted now.
28
+ //
29
+ // NOT `x jobs drain --to memory` either, which it said until 2026-09, and that one was a route
30
+ // into data loss: the target is a Map inside the command's own process, so the drain acked every
31
+ // durable row and lost the copy at exit. `x jobs` refuses that value by name now. There is no
32
+ // drain to run in its place, and the reason is in this file — `enqueue` below refuses too, so
33
+ // nothing was ever queued onto this driver and the queue is untouched.
34
+ //
26
35
  // The redis driver lands in v2; there is no flag that turns this one on.
27
36
  const FIX =
28
- 'call setJobDriver(createPgDriver()) at boot instead of this driver, then move what is already queued: x jobs drain --to memory --json';
37
+ 'call setJobDriver(createPgDriver()) at boot instead of this driver; nothing needs moving first, because enqueue here refuses too, so no job was ever written to it';
29
38
 
30
39
  const unavailable = (method: string): never => {
31
40
  throw new JobsNotImplementedError({ feature: `redis jobs driver (${method})`, fix: FIX });
package/src/errors.ts CHANGED
@@ -39,13 +39,15 @@ export const JOB_OWNED_ERROR_CODES = [
39
39
  ] as const;
40
40
 
41
41
  /**
42
- * `X_NOT_IMPLEMENTED` and `X_ABORTED` are `@ultimat3/core`'s. `JobsNotImplementedError` and
43
- * `JobAbortedError` below throw them; jobs keeps no title for either, because the copy this file
44
- * used to hold was a second title that nothing would have failed on once core's changed.
42
+ * `X_NOT_IMPLEMENTED`, `X_ABORTED` and `X_DRAINING` are `@ultimat3/core`'s. `JobsNotImplementedError`,
43
+ * `JobAbortedError` and `JobDrainedError` below throw them; jobs keeps no title for any of the
44
+ * three, because the copy this file used to hold was a second title that nothing would have failed
45
+ * on once core's changed. Listed here all the same, so `JobErrorCode` can name every code a job
46
+ * can see — `X_DRAINING` was thrown for a day before it was, and the type said it could not be.
45
47
  */
46
- export const JOB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ABORTED'] as const;
48
+ export const JOB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ABORTED', 'X_DRAINING'] as const;
47
49
 
48
- /** Every code jobs can throw: the ones it owns plus the one it borrows. */
50
+ /** Every code jobs can throw: the ones it owns plus the ones it borrows. */
49
51
  export const JOB_ERROR_CODES = [...JOB_OWNED_ERROR_CODES, ...JOB_BORROWED_ERROR_CODES] as const;
50
52
 
51
53
  export type JobOwnedErrorCode = (typeof JOB_OWNED_ERROR_CODES)[number];
@@ -271,6 +273,27 @@ export class JobAbortedError extends UltimateError {
271
273
  }
272
274
  }
273
275
 
276
+ /**
277
+ * The worker holding this attempt received SIGTERM. Handed to the run as `ctx.signal`'s reason
278
+ * the moment the drain's `accept` phase runs — before core's in-flight wait, not at the deadline
279
+ * that ends it — so a body reading the one cancellation seam learns the process is going away
280
+ * while there is still budget to unwind in.
281
+ *
282
+ * Core's `X_DRAINING` rather than a code of jobs' own, for `JobAbortedError`'s reason: the
283
+ * framework already means exactly one thing by "the process is draining", it is already
284
+ * classified `retryable`, and `executeJob` reads the CODE off the run signal's reason to tell a
285
+ * drained attempt from a timed-out or lease-lost one — the drained one is handed back uncounted.
286
+ */
287
+ export class JobDrainedError extends UltimateError {
288
+ constructor(input: { workerId: string; signal: string }) {
289
+ super({
290
+ code: 'X_DRAINING',
291
+ cause: `worker "${input.workerId}" is draining (${input.signal}) — this attempt is cut short and the job handed back to the queue with the attempt uncounted`,
292
+ fix: 'nothing on the job: another worker claims it. To unwind inside the drain budget instead of being killed at it, pass ctx.signal to every outbound call — fetch(url, { signal: ctx.signal }) — and call throwIfAborted(ctx) between steps',
293
+ });
294
+ }
295
+ }
296
+
274
297
  /** Retries exhausted. The job is in the dead-letter queue, not lost. */
275
298
  export class JobMaxAttemptsError extends UltimateError {
276
299
  constructor(input: { job: string; jobId: string; attempts: number; lastError: string }) {
package/src/execute.ts CHANGED
@@ -28,7 +28,13 @@ import type { EventLookup, StepRecord } from './steps';
28
28
  import { createStepRunner, isStepSuspension } from './steps';
29
29
  import { jobRunActor } from './tenant';
30
30
 
31
- export type JobOutcome = 'completed' | 'suspended' | 'retried' | 'dead-lettered';
31
+ /**
32
+ * How one attempt ended. `interrupted` is the worker's drain cutting the attempt short: the job is
33
+ * back in the ready bucket with the attempt UNCOUNTED, because the process ended it and not the
34
+ * job — filed as `retried`, a deploy would burn an attempt per job it held, and with
35
+ * `attempts: 1` dead-letter it.
36
+ */
37
+ export type JobOutcome = 'completed' | 'suspended' | 'retried' | 'dead-lettered' | 'interrupted';
32
38
 
33
39
  /** Stands in for a caller with nothing to cancel, so the composition below has one shape. */
34
40
  const NEVER_ABORTED = new AbortController().signal;
@@ -188,6 +194,33 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
188
194
  }
189
195
 
190
196
  const message = renderThrowable(error);
197
+ if (drainedBy(signal)) {
198
+ // The worker's drain told this body to stop, and it did. Whatever it stopped WITH is the
199
+ // framework's doing — the reason itself back from `fetch`, `throwIfAborted`'s `X_ABORTED`,
200
+ // a fenced step write, or an app's own coded error for a child the shutdown killed — so
201
+ // the attempt is handed back rather than failed: `countsAsAttempt: false`, no park, no
202
+ // dead letter, claimable at once by the worker replacing this one. Read off the SIGNAL and
203
+ // not the error, because the body's error is not always the signal's reason, and an
204
+ // attempt burned per deploy is the "always twice" draining exists to prevent. The `error`
205
+ // is still recorded on the row: `x jobs show` should say why the last attempt ended.
206
+ await driver.nack(claimed.id, { delayMs: 0, error: message, countsAsAttempt: false });
207
+ logger.info('jobs.attempt.interrupted', {
208
+ job: handle.name,
209
+ jobId: claimed.id,
210
+ attempt: claimed.attempt,
211
+ error: message,
212
+ });
213
+ return settle({
214
+ outcome: 'interrupted',
215
+ jobId: claimed.id,
216
+ job: handle.name,
217
+ attempt: claimed.attempt,
218
+ durationMs: nowMs(options.clock) - startedAt,
219
+ error: message,
220
+ steps: [],
221
+ replayed: [],
222
+ });
223
+ }
191
224
  // The ERROR decides too, not only the attempt count. A `terminal` code — a rotated password,
192
225
  // a schema mismatch, a permission denial — fails identically on every remaining attempt, so
193
226
  // spending them is a queue slot, a provider bill and, at a site that locks an account after
@@ -317,6 +350,15 @@ function raceTimeout(
317
350
  });
318
351
  }
319
352
 
353
+ /**
354
+ * The run was cancelled by the worker's drain: the code `JobDrainedError` carries, read off the
355
+ * signal. The FIRST reason wins on a controller, so a timeout that fired before the drain still
356
+ * reports as a timeout — the drain only claims an attempt it ended.
357
+ */
358
+ function drainedBy(signal: AbortSignal): boolean {
359
+ return signal.aborted && isUltimateError(signal.reason) && signal.reason.code === 'X_DRAINING';
360
+ }
361
+
320
362
  /** The body stopped because we cancelled it: our own reason back, or a fenced step write. */
321
363
  function isCancellation(error: unknown, reason: unknown): boolean {
322
364
  return error === reason || (isUltimateError(error) && error.code === 'X_ABORTED');
package/src/index.ts CHANGED
@@ -159,6 +159,7 @@ export {
159
159
  JOB_ERROR_CODES,
160
160
  JOB_ERROR_TITLES,
161
161
  JobAbortedError,
162
+ JobDrainedError,
162
163
  JobDuplicateError,
163
164
  JobMaxAttemptsError,
164
165
  JobNameTakenError,
@@ -233,14 +234,11 @@ export type {
233
234
  MemoryOutboxStore,
234
235
  OutboxDeps,
235
236
  OutboxRecord,
236
- OutboxRelay,
237
237
  OutboxStore,
238
- RelayOptions,
239
238
  } from './outbox';
240
239
  export {
241
240
  createJobsFacade,
242
241
  createMemoryOutboxStore,
243
- createOutboxRelay,
244
242
  enqueueInTx,
245
243
  jobsFacade,
246
244
  resetJobsFacade,
@@ -251,6 +249,8 @@ export {
251
249
  export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
252
250
  export type { PgOutboxOptions } from './outbox-pg';
253
251
  export { createPgOutboxStore } from './outbox-pg';
252
+ export type { OutboxRelay, RelayOptions } from './outbox-relay';
253
+ export { createOutboxRelay } from './outbox-relay';
254
254
  export type {
255
255
  PurgeDefinition,
256
256
  PurgeInput,
@@ -290,7 +290,6 @@ export type {
290
290
  WaitForEventOptions,
291
291
  } from './steps';
292
292
  export {
293
- createMemoryStepStore,
294
293
  createStepRunner,
295
294
  isStepStatus,
296
295
  isStepSuspension,
@@ -298,6 +297,7 @@ export {
298
297
  STEP_STATUSES,
299
298
  StepSuspension,
300
299
  } from './steps';
300
+ export { createMemoryStepStore } from './steps-memory';
301
301
  export type {
302
302
  CatchUpPolicy,
303
303
  TaskDefinition,
package/src/metrics.ts CHANGED
@@ -14,6 +14,24 @@
14
14
 
15
15
  import type { Gauge } from '@ultimat3/core';
16
16
  import { gauge } from '@ultimat3/core';
17
+ import type { JobOutcome } from './execute';
18
+
19
+ /**
20
+ * `JobOutcome` -> the `jobs_total` label, and `null` for the outcomes that are not one. `suspended`
21
+ * is deliberately unmapped: parking a run is control flow, so counting it would make every
22
+ * `step.sleep` read as a finished job and make the failure ratio meaningless. `interrupted` for
23
+ * the same reason: a deploy cutting a job short is the process's doing, and a failure ratio that
24
+ * spikes on every rollout is a page nobody answers. Read by the worker's one `recordJob` site.
25
+ */
26
+ export const JOB_OUTCOME_LABELS = Object.freeze<
27
+ Record<JobOutcome, 'ok' | 'failed' | 'dead' | null>
28
+ >({
29
+ completed: 'ok',
30
+ suspended: null,
31
+ retried: 'failed',
32
+ 'dead-lettered': 'dead',
33
+ interrupted: null,
34
+ });
17
35
 
18
36
  /** Seconds and not milliseconds: every Prometheus duration is seconds, and the alert is `> 300`. */
19
37
  export const queueOldestReady: Gauge = gauge('queue_oldest_ready_seconds', {