@ultimat3/jobs 19.3.1 → 19.3.3

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
@@ -293,6 +293,31 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
293
293
  promises; `jobs.worker.drain-abandoned` names it, with the `configureLifecycle({ deadlineMs })`
294
294
  raise as its fix. **A worker always REACHES `'stopped'`**, which is what makes `stop()`'s
295
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.
296
321
  - **One teardown, joined.** `stop()` shares the in-flight teardown promise, so a SIGTERM landing
297
322
  on a manual stop waits out the same in-flight jobs instead of closing the driver underneath
298
323
  it. The promise is cleared as it settles, so a worker that started again tears down again
@@ -937,7 +962,8 @@ picture from the other side.
937
962
  | `execute.ts` | `executeJob` — one claimed job run and settled, and the run's deadline/cancel |
938
963
  | `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
939
964
  | `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
940
- | `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` |
941
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 |
942
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` |
943
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,8 +621,9 @@ 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
 
606
- `start()` registers the same two shutdown hooks `createWorker` does — `accept` stops polling,
607
- `close` waits out the pass in flight under the drain's deadline — and `stop()` hands both back.
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.
608
627
  `drainOnShutdown: false` opts out, for a caller that drives its own teardown.
609
628
 
610
629
  with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "19.3.1",
3
+ "version": "19.3.3",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,10 +32,10 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "19.3.1",
36
- "@ultimat3/db": "19.3.1",
37
- "@ultimat3/entity": "19.3.1",
38
- "@ultimat3/schema": "19.3.1",
39
- "@ultimat3/time": "19.3.1"
35
+ "@ultimat3/core": "19.3.3",
36
+ "@ultimat3/db": "19.3.3",
37
+ "@ultimat3/entity": "19.3.3",
38
+ "@ultimat3/schema": "19.3.3",
39
+ "@ultimat3/time": "19.3.3"
40
40
  }
41
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
  }
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,
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', {
package/src/worker-run.ts CHANGED
@@ -27,6 +27,12 @@ export interface RunClaimedOptions {
27
27
  readonly heartbeatIntervalMs: number;
28
28
  readonly clock?: Clock;
29
29
  readonly events?: EventLookup;
30
+ /**
31
+ * The worker's drain, composed into every run it starts: aborted with a `JobDrainedError` when
32
+ * the process is going away, so the body hears it on `ctx.signal` — the one seam it already
33
+ * reads — before core's in-flight wait starts spending the budget on it.
34
+ */
35
+ readonly drain?: AbortSignal;
30
36
  }
31
37
 
32
38
  /** A name this deploy does not know, parked rather than failed — almost always a deploy skew. */
@@ -95,8 +101,10 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
95
101
  // controller this worker owns rather than `AbortSignal.any`, for two reasons: it is handed BACK
96
102
  // when the run settles (an app whose `context()` carries a process-lifetime signal was
97
103
  // accumulating one composite per job), and the worker can abort it itself — which is the only
98
- // way a fleet slot taken by somebody else reaches the body running under it.
99
- runSignal = createRunSignal([base.signal, heartbeat.signal]);
104
+ // way a fleet slot taken by somebody else reaches the body running under it. The worker's
105
+ // drain is the third source: SIGTERM reaches the body through the same signal, carrying the
106
+ // `X_DRAINING` reason `executeJob` reads to hand the attempt back uncounted.
107
+ runSignal = createRunSignal([base.signal, heartbeat.signal, options.drain]);
100
108
  const signal = runSignal;
101
109
  const ctx: Ctx = { ...base, signal: signal.signal };
102
110
 
@@ -0,0 +1,56 @@
1
+ // The `worker` role's public contract: what `createWorker` takes, what it hands back, and what
2
+ // `stats()` reports. Apart from `worker.ts` because that file's job is the claim loop and the
3
+ // drain, and a contract three files import should not sit under 500 lines of loop.
4
+
5
+ import type { Clock, Ctx } from '@ultimat3/core';
6
+ import type { JobDriver, QueueStats } from './driver';
7
+ import type { JobExecution } from './execute';
8
+ import type { Limiter } from './limits';
9
+ import type { EventLookup } from './steps';
10
+
11
+ export interface WorkerOptions {
12
+ readonly driver: JobDriver;
13
+ /** Queues this process serves. Default `['default']`. */
14
+ readonly queues?: readonly string[];
15
+ /** Slots per queue. A number applies to every queue. */
16
+ readonly concurrency?: number | Readonly<Record<string, number>>;
17
+ readonly limiter?: Limiter;
18
+ readonly clock?: Clock;
19
+ readonly events?: EventLookup;
20
+ /** Supplies the ambient Ctx for a job run; the app wires ALS + tenant here. */
21
+ readonly context: () => Ctx;
22
+ readonly visibilityTimeoutMs?: number;
23
+ readonly pollIntervalMs?: number;
24
+ readonly heartbeatIntervalMs?: number;
25
+ readonly workerId?: string;
26
+ /** Default true. Registers a SIGTERM drain via `onShutdown`. */
27
+ readonly drainOnShutdown?: boolean;
28
+ }
29
+
30
+ export interface WorkerStats {
31
+ readonly workerId: string;
32
+ readonly queues: readonly string[];
33
+ readonly state: 'idle' | 'running' | 'draining' | 'stopped';
34
+ readonly inFlight: number;
35
+ readonly processed: number;
36
+ readonly failed: number;
37
+ readonly suspended: number;
38
+ readonly deadLettered: number;
39
+ /** Attempts this worker's drain cut short and handed back uncounted — see `JobDrainedError`. */
40
+ readonly interrupted: number;
41
+ readonly queueDepth: readonly QueueStats[];
42
+ }
43
+
44
+ export interface Worker {
45
+ start(): void;
46
+ /** One claim+run round. Returns jobs processed. Tests drive this instead of the timer. */
47
+ tick(): Promise<readonly JobExecution[]>;
48
+ /**
49
+ * Stop claiming, wait for every job this worker holds, close the driver. Unbounded, and it
50
+ * aborts nothing: a caller that asked wants its work finished. SIGTERM takes the other path —
51
+ * the shutdown hooks `start()` registers abort every held run's `ctx.signal` and wait under
52
+ * the lifecycle's deadline.
53
+ */
54
+ stop(reason?: string): Promise<void>;
55
+ stats(): Promise<WorkerStats>;
56
+ }
package/src/worker.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // bug here — the visibility timeout re-delivers it — but a worker that exits mid-job on EVERY
4
4
  // deploy turns "at least once" into "always twice", so draining is on by default.
5
5
 
6
- import type { Clock, Ctx } from '@ultimat3/core';
6
+ import type { ShutdownReason } from '@ultimat3/core';
7
7
  import {
8
8
  beginWork,
9
9
  logger,
@@ -14,19 +14,18 @@ import {
14
14
  uuid,
15
15
  } from '@ultimat3/core';
16
16
  import { nowMs } from './clock';
17
- import { settleAllBy } from './drain-wait';
18
- import type { ClaimedJob, JobDriver, QueueStats } from './driver';
17
+ import { createDrainBudget, settleAllBy } from './drain-wait';
18
+ import type { ClaimedJob } from './driver';
19
19
  import { DEFAULT_QUEUE } from './driver';
20
- import { ConcurrencyUnenforceableError } from './errors';
21
- import type { JobExecution, JobOutcome } from './execute';
20
+ import { ConcurrencyUnenforceableError, JobDrainedError } from './errors';
21
+ import type { JobExecution } from './execute';
22
22
  import { getJob, registeredJobs } from './job';
23
- import type { Limiter } from './limits';
24
23
  import { createLimiter } from './limits';
25
- import { recordQueueDeadJobs, recordQueueOldestReady } from './metrics';
26
- import type { EventLookup } from './steps';
24
+ import { JOB_OUTCOME_LABELS, recordQueueDeadJobs, recordQueueOldestReady } from './metrics';
27
25
  import { createFleetSlots } from './worker-fleet-slots';
28
26
  import { resolveWorkerTimings } from './worker-options';
29
27
  import { runClaimedJob } from './worker-run';
28
+ import type { Worker, WorkerOptions, WorkerStats } from './worker-types';
30
29
 
31
30
  /**
32
31
  * How often the claim loop republishes `queue_depth`. Its own interval, not `pollIntervalMs`:
@@ -36,56 +35,7 @@ import { runClaimedJob } from './worker-run';
36
35
  */
37
36
  const QUEUE_DEPTH_INTERVAL_MS = 15_000;
38
37
 
39
- /**
40
- * `JobOutcome` -> the `jobs_total` label, and `null` for the outcome that is not one. `suspended`
41
- * is deliberately unmapped: parking a run is control flow, so counting it would make every
42
- * `step.sleep` read as a finished job and make the failure ratio meaningless.
43
- */
44
- const JOB_OUTCOME_LABELS = Object.freeze<Record<JobOutcome, 'ok' | 'failed' | 'dead' | null>>({
45
- completed: 'ok',
46
- suspended: null,
47
- retried: 'failed',
48
- 'dead-lettered': 'dead',
49
- });
50
-
51
- export interface WorkerOptions {
52
- readonly driver: JobDriver;
53
- /** Queues this process serves. Default `['default']`. */
54
- readonly queues?: readonly string[];
55
- /** Slots per queue. A number applies to every queue. */
56
- readonly concurrency?: number | Readonly<Record<string, number>>;
57
- readonly limiter?: Limiter;
58
- readonly clock?: Clock;
59
- readonly events?: EventLookup;
60
- /** Supplies the ambient Ctx for a job run; the app wires ALS + tenant here. */
61
- readonly context: () => Ctx;
62
- readonly visibilityTimeoutMs?: number;
63
- readonly pollIntervalMs?: number;
64
- readonly heartbeatIntervalMs?: number;
65
- readonly workerId?: string;
66
- /** Default true. Registers a SIGTERM drain via `onShutdown`. */
67
- readonly drainOnShutdown?: boolean;
68
- }
69
-
70
- export interface WorkerStats {
71
- readonly workerId: string;
72
- readonly queues: readonly string[];
73
- readonly state: 'idle' | 'running' | 'draining' | 'stopped';
74
- readonly inFlight: number;
75
- readonly processed: number;
76
- readonly failed: number;
77
- readonly suspended: number;
78
- readonly deadLettered: number;
79
- readonly queueDepth: readonly QueueStats[];
80
- }
81
-
82
- export interface Worker {
83
- start(): void;
84
- /** One claim+run round. Returns jobs processed. Tests drive this instead of the timer. */
85
- tick(): Promise<readonly JobExecution[]>;
86
- stop(reason?: string): Promise<void>;
87
- stats(): Promise<WorkerStats>;
88
- }
38
+ export type { Worker, WorkerOptions, WorkerStats } from './worker-types';
89
39
 
90
40
  export function createWorker(options: WorkerOptions): Worker {
91
41
  const workerId = options.workerId ?? `worker-${uuid()}`;
@@ -111,6 +61,23 @@ export function createWorker(options: WorkerOptions): Worker {
111
61
  * `inFlight` waited on a set the round it was racing had not finished filling.
112
62
  */
113
63
  const rounds = new Set<Promise<unknown>>();
64
+ /**
65
+ * The drain, as every run this worker starts hears it: composed into each run's `ctx.signal`
66
+ * (`worker-run.ts`), aborted by the `accept` hook with a `JobDrainedError`. ONE controller and
67
+ * not one per run, because the fact it carries — "this process is going away" — is one fact.
68
+ * Replaced with a fresh one when a teardown ends: a controller aborted once stays aborted, and
69
+ * a restarted worker would otherwise hand every job it claimed a signal born cancelled.
70
+ */
71
+ let drainSignal = new AbortController();
72
+ /**
73
+ * The deadline the teardown waits under. `undefined` for a manual `stop()`, bound the moment a
74
+ * shutdown lands — and bound LATE when that shutdown lands on a teardown already in flight: the
75
+ * `close` hook joins the memoised `stopping` rather than starting a second, and until 2026-09-07
76
+ * the teardown it joined kept the `undefined` it was started with. Core abandoned the hook at
77
+ * the deadline; the worker sat on a body ignoring `ctx.signal` with its driver open. Fresh per
78
+ * teardown, for the controller's reason: a restarted worker's manual stop is unbounded again.
79
+ */
80
+ let budget = createDrainBudget();
114
81
  let state: WorkerStats['state'] = 'idle';
115
82
  let loop: ReturnType<typeof setTimeout> | undefined;
116
83
  /**
@@ -125,6 +92,7 @@ export function createWorker(options: WorkerOptions): Worker {
125
92
  let failed = 0;
126
93
  let suspended = 0;
127
94
  let deadLettered = 0;
95
+ let interrupted = 0;
128
96
  let depthPublishedAt = Number.NEGATIVE_INFINITY;
129
97
 
130
98
  /**
@@ -167,6 +135,7 @@ export function createWorker(options: WorkerOptions): Worker {
167
135
  workerId,
168
136
  visibilityTimeoutMs,
169
137
  heartbeatIntervalMs,
138
+ drain: drainSignal.signal,
170
139
  ...(options.clock === undefined ? {} : { clock: options.clock }),
171
140
  ...(options.events === undefined ? {} : { events: options.events }),
172
141
  });
@@ -275,6 +244,7 @@ export function createWorker(options: WorkerOptions): Worker {
275
244
  if (execution.outcome === 'completed') processed += 1;
276
245
  else if (execution.outcome === 'suspended') suspended += 1;
277
246
  else if (execution.outcome === 'retried') failed += 1;
247
+ else if (execution.outcome === 'interrupted') interrupted += 1;
278
248
  else deadLettered += 1;
279
249
  // The other half of this package's metrics contract: `queue_depth` says how much work
280
250
  // is waiting, `jobs_total` says whether any of it is succeeding. Depth alone cannot
@@ -368,19 +338,39 @@ export function createWorker(options: WorkerOptions): Worker {
368
338
  };
369
339
 
370
340
  /**
371
- * The whole of the `accept` phase: stop taking work, and nothing else. Synchronous on purpose
372
- * a phase whose job is to be over before the load balancer's next health check must not contain
373
- * a wait, and the hook behind this one is somebody else's "stop listening".
341
+ * The whole of the `accept` phase: stop taking work, tell the work already held, and nothing
342
+ * else. Synchronous on purpose — a phase whose job is to be over before the load balancer's next
343
+ * health check must not contain a wait, and the hook behind this one is somebody else's "stop
344
+ * listening". An abort is synchronous and costs nothing, which is why it belongs HERE and not in
345
+ * the teardown: core runs `accept`, then waits out in-flight work (every claimed job is
346
+ * `beginWork()`ed) under the same budget, then `close`. Told in `close`, a body that reads
347
+ * `ctx.signal` would hear it after the in-flight wait had already spent the whole budget on it —
348
+ * which is what happened until 2026-09-07: a job that would have stopped in a second was waited
349
+ * on for the full deadline and abandoned there, exactly like one that ignores the signal.
350
+ *
351
+ * Only a SHUTDOWN aborts, and only a shutdown binds the budget. A manual `stop()` passes
352
+ * nothing: a caller that asked has no budget to spend and wants its work finished, the same
353
+ * line `settleAllBy` draws for the wait. The bind comes BEFORE the abort's once-guard and on
354
+ * every call, because the second shutdown to reach a worker is the one that finds the abort
355
+ * already fired and the teardown already waiting — with no deadline, if the first was manual.
374
356
  */
375
- const stopAccepting = (): void => {
357
+ const stopAccepting = (shutdown?: ShutdownReason): void => {
376
358
  if (state === 'stopped') return;
377
359
  state = 'draining';
378
360
  if (loop !== undefined) clearTimeout(loop);
379
361
  loop = undefined;
362
+ if (shutdown === undefined) return;
363
+ budget.bind(shutdown.deadlineAt);
364
+ if (drainSignal.signal.aborted) return;
365
+ logger.info('jobs.worker.drain-signalled', {
366
+ workerId,
367
+ signal: shutdown.signal,
368
+ inFlight: inFlight.size,
369
+ });
370
+ drainSignal.abort(new JobDrainedError({ workerId, signal: shutdown.signal }));
380
371
  };
381
372
 
382
- const teardown = async (reason: string, deadlineAt?: number): Promise<void> => {
383
- stopAccepting();
373
+ const teardown = async (reason: string): Promise<void> => {
384
374
  logger.info('jobs.worker.draining', { workerId, reason, inFlight: inFlight.size });
385
375
  try {
386
376
  // Stop claiming, finish what we hold, then close. Anything else re-runs work on deploy.
@@ -388,13 +378,14 @@ export function createWorker(options: WorkerOptions): Worker {
388
378
  // `claim()`, and the jobs it starts join `inFlight` after any snapshot taken here — so a
389
379
  // drain that waited on `inFlight` alone closed the driver under a job that had just begun.
390
380
  //
391
- // Both waits share ONE deadline on the SIGTERM path (`undefined` on a manual stop, which
392
- // waits as long as its jobs take). Nothing can kill a body that ignores `ctx.signal`, so an
393
- // unbounded wait here is a teardown that never ends: driver never closed, state never past
394
- // 'draining', and the memoized `stopping` every later `stop()` joins never settling.
395
- // Abandoning costs a lapsed lease and a redelivered job at-least-once, as promised.
396
- const rounded = await settleAllBy([...rounds], deadlineAt);
397
- const drained = (await settleAllBy([...inFlight], deadlineAt)) && rounded;
381
+ // Both waits share ONE budget: unbound on a manual stop, which waits as long as its jobs
382
+ // take, and bound by the SIGTERM whether it arrived before this teardown or lands in the
383
+ // middle of it. Nothing can kill a body that ignores `ctx.signal`, so an unbounded wait
384
+ // here is a teardown that never ends: driver never closed, state never past 'draining', and
385
+ // the memoized `stopping` every later `stop()` joins never settling. Abandoning costs a
386
+ // lapsed lease and a redelivered job — at-least-once, as promised.
387
+ const rounded = await settleAllBy([...rounds], budget);
388
+ const drained = (await settleAllBy([...inFlight], budget)) && rounded;
398
389
  if (!drained) {
399
390
  logger.warn('jobs.worker.drain-abandoned', {
400
391
  workerId,
@@ -414,20 +405,28 @@ export function createWorker(options: WorkerOptions): Worker {
414
405
  state = 'stopped';
415
406
  for (const release of releaseShutdownHooks) release();
416
407
  releaseShutdownHooks = [];
408
+ // A run this drain abandoned still follows the old controller through its own composition;
409
+ // the next start's jobs must not. Fresh here, in the one place a teardown always reaches —
410
+ // and the budget with it, or the next manual stop would inherit a deadline already spent.
411
+ drainSignal = new AbortController();
412
+ budget = createDrainBudget();
417
413
  }
418
414
  };
419
415
 
420
- const stop = async (reason = 'stop', deadlineAt?: number): Promise<void> => {
416
+ const stop = async (reason = 'stop', shutdown?: ShutdownReason): Promise<void> => {
421
417
  // Answered immediately once this worker is done: the teardown always REACHES 'stopped' (its
422
418
  // waits are bounded and the state is set in a `finally`), so a caller landing after an
423
419
  // abandoned drain gets an answer rather than joining a promise that never settles.
424
420
  if (state === 'stopped') return;
425
- // One teardown, joined rather than repeated: a SIGTERM landing on a manual stop must wait out
426
- // the same in-flight work, not close the driver a second time underneath it. Cleared as it
427
- // settles, so a worker that started again tears down again instead of joining a promise that
428
- // settled a lifetime ago. A close that threw still stopped this worker — the failure is the
429
- // caller's to see on the promise it awaited, not a teardown to run twice.
430
- stopping ??= teardown(reason, deadlineAt).finally(() => {
421
+ // Before the join, every time: a SIGTERM landing on a manual stop still aborts every held
422
+ // run's `ctx.signal` and binds the teardown already waiting to the shutdown's deadline.
423
+ stopAccepting(shutdown);
424
+ // One teardown, joined rather than repeated: that SIGTERM must wait out the same in-flight
425
+ // work, not close the driver a second time underneath it. Cleared as it settles, so a worker
426
+ // that started again tears down again instead of joining a promise that settled a lifetime
427
+ // ago. A close that threw still stopped this worker — the failure is the caller's to see on
428
+ // the promise it awaited, not a teardown to run twice.
429
+ stopping ??= teardown(reason).finally(() => {
431
430
  stopping = undefined;
432
431
  });
433
432
  await stopping;
@@ -453,19 +452,22 @@ export function createWorker(options: WorkerOptions): Worker {
453
452
  }
454
453
  state = 'running';
455
454
  logger.info('jobs.worker.started', { workerId, queues });
456
- // TWO hooks, for the two phases that answer two questions. `accept` stops claiming and
457
- // returns, so every hook behind it — the HTTP server's "stop listening", the sync node's
458
- // "stop upgrading" — runs while the budget is still whole; one hook doing both spent all of
459
- // it in the phase whose whole purpose is to be quick. `close` waits out what this worker
460
- // holds and closes the driver, bounded by the deadline the hook is handed.
455
+ // TWO hooks, for the two phases that answer two questions. `accept` stops claiming, aborts
456
+ // every held run's `ctx.signal` and returns, so every hook behind it — the HTTP server's
457
+ // "stop listening", the sync node's "stop upgrading" — runs while the budget is still whole;
458
+ // one hook doing both spent all of it in the phase whose whole purpose is to be quick.
459
+ // `close` waits out what this worker holds and closes the driver, bounded by the deadline
460
+ // the hook is handed.
461
461
  //
462
462
  // Both unregisters are kept, never discarded: `stop()` hands them back, so
463
463
  // start -> stop -> start holds one pair rather than one per start, each retaining the
464
464
  // driver of a worker that is already gone.
465
465
  if (options.drainOnShutdown !== false) {
466
466
  releaseShutdownHooks = [
467
- onShutdown(`jobs.worker.${workerId}.accept`, stopAccepting, { phase: 'accept' }),
468
- onShutdown(`jobs.worker.${workerId}`, (reason) => stop('SIGTERM', reason.deadlineAt), {
467
+ onShutdown(`jobs.worker.${workerId}.accept`, (reason) => stopAccepting(reason), {
468
+ phase: 'accept',
469
+ }),
470
+ onShutdown(`jobs.worker.${workerId}`, (reason) => stop(reason.signal, reason), {
469
471
  phase: 'close',
470
472
  }),
471
473
  ];
@@ -484,6 +486,7 @@ export function createWorker(options: WorkerOptions): Worker {
484
486
  failed,
485
487
  suspended,
486
488
  deadLettered,
489
+ interrupted,
487
490
  queueDepth: [...(await options.driver.stats())],
488
491
  };
489
492
  },