@ultimat3/jobs 24.0.0 → 25.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.
Files changed (54) hide show
  1. package/CLAUDE.md +25 -27
  2. package/README.md +50 -34
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +1 -1
  6. package/src/backfill-rate.ts +2 -2
  7. package/src/backfill.ts +2 -2
  8. package/src/counters.ts +1 -1
  9. package/src/driver-memory-operator.ts +3 -3
  10. package/src/driver-memory.ts +14 -17
  11. package/src/driver-pg-settle-sql.ts +2 -3
  12. package/src/driver-pg-sql.ts +1 -1
  13. package/src/driver-pg.ts +18 -18
  14. package/src/driver.ts +9 -15
  15. package/src/errors-concurrency.ts +1 -1
  16. package/src/errors.ts +5 -5
  17. package/src/events-pg.ts +4 -4
  18. package/src/events.ts +6 -6
  19. package/src/execute.ts +46 -5
  20. package/src/export.ts +2 -2
  21. package/src/heartbeat.ts +7 -2
  22. package/src/index.ts +37 -97
  23. package/src/inspect-operator.ts +1 -1
  24. package/src/inspect.ts +1 -1
  25. package/src/job.ts +1 -1
  26. package/src/leases.ts +1 -1
  27. package/src/limits.ts +13 -8
  28. package/src/outbox-lease.ts +1 -1
  29. package/src/outbox-pg.ts +5 -5
  30. package/src/outbox-relay.ts +6 -6
  31. package/src/outbox.ts +13 -13
  32. package/src/renewal-timer.ts +1 -1
  33. package/src/retry-classification.ts +52 -15
  34. package/src/scheduler-leader.ts +2 -2
  35. package/src/scheduler-pg.ts +16 -14
  36. package/src/scheduler-state.ts +2 -2
  37. package/src/scheduler.ts +5 -5
  38. package/src/steps-memory.ts +2 -2
  39. package/src/steps.ts +35 -7
  40. package/src/task.ts +1 -1
  41. package/src/webhook-attempt.ts +206 -0
  42. package/src/webhook-ledger.ts +15 -4
  43. package/src/webhook.ts +64 -118
  44. package/src/worker-admit.ts +7 -3
  45. package/src/worker-drain-cutoff.ts +110 -0
  46. package/src/worker-fleet-slots.ts +5 -2
  47. package/src/worker-held.ts +100 -0
  48. package/src/worker-options.ts +19 -15
  49. package/src/worker-run.ts +38 -8
  50. package/src/worker-tally.ts +53 -0
  51. package/src/worker-types.ts +7 -4
  52. package/src/worker.ts +85 -77
  53. package/src/driver-nats.ts +0 -89
  54. package/src/driver-redis.ts +0 -92
package/src/worker-run.ts CHANGED
@@ -17,6 +17,7 @@ import type { RunSignal } from './run-signal';
17
17
  import { createRunSignal } from './run-signal';
18
18
  import type { EventLookup } from './steps';
19
19
  import type { FleetSlots } from './worker-fleet-slots';
20
+ import { handBack } from './worker-hand-back';
20
21
 
21
22
  export interface RunClaimedOptions {
22
23
  readonly driver: JobDriver;
@@ -27,14 +28,16 @@ export interface RunClaimedOptions {
27
28
  readonly workerId: string;
28
29
  readonly visibilityTimeoutMs: number;
29
30
  readonly heartbeatIntervalMs: number;
31
+ /** How soon a run whose wiring threw before its body started is claimable again — a shed's. */
32
+ readonly pollIntervalMs: number;
30
33
  readonly clock?: Clock;
31
34
  /** What every renewal runs on (`renewal-timer.ts`). Default: a real, unrefed interval. */
32
35
  readonly schedule?: IntervalScheduler;
33
36
  readonly events?: EventLookup;
34
37
  /**
35
- * The worker's drain, composed into every run it starts: aborted with a `JobDrainedError` when
36
- * the process is going away, so the body hears it on `ctx.signal` — the one seam it already
37
- * reads — before core's in-flight wait starts spending the budget on it.
38
+ * The worker's drain, composed into every run it starts: aborted with a `JobDrainedError` at
39
+ * the drain's cut-off — near the deadline, never at SIGTERM (`worker-drain-cutoff.ts`) — so a
40
+ * body still running then hears it on `ctx.signal`, the one seam it already reads.
38
41
  */
39
42
  readonly drain?: AbortSignal;
40
43
  /** Why this run may not start — handed to `executeJob`, which fails the attempt with it. */
@@ -73,10 +76,28 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
73
76
  return unknownJob(claimed);
74
77
  }
75
78
 
79
+ // A throw from the wiring below means the body never started, so the row goes BACK exactly as a
80
+ // shed returns one — uncounted, no `lastError` — before the throw reaches the round. Rethrowing
81
+ // alone left it `running` until the lease lapsed, an attempt spent on nothing; on the final
82
+ // attempt the claim then dead-letters a job whose body never ran. Once `executeJob` is entered
83
+ // the attempt is real and settles itself, so `started` is set at that door and nowhere else.
84
+ let started = false;
85
+ const handedBack = async (error: unknown): Promise<never> => {
86
+ if (!started) {
87
+ await handBack(driver, [claimed], { delayMs: options.pollIntervalMs, workerId });
88
+ }
89
+ throw error;
90
+ };
91
+
76
92
  // Read BEFORE the timers start. `context()` is the app's own function and it can throw; started
77
93
  // first, a heartbeat interval and a slot renewal were left running for a job that never ran,
78
94
  // with nothing left holding a reference to stop them.
79
- const base = options.context();
95
+ let base: Ctx;
96
+ try {
97
+ base = options.context();
98
+ } catch (error) {
99
+ return handedBack(error);
100
+ }
80
101
 
81
102
  // The lease, kept alive and NOT kept quiet: a renewal that stops landing means the queue hands
82
103
  // this job to another worker while this one is still running it, and `.catch(() => undefined)`
@@ -110,7 +131,7 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
110
131
  // when the run settles (an app whose `context()` carries a process-lifetime signal was
111
132
  // accumulating one composite per job), and the worker can abort it itself — which is the only
112
133
  // way a fleet slot taken by somebody else reaches the body running under it. The worker's
113
- // drain is the third source: SIGTERM reaches the body through the same signal, carrying the
134
+ // drain is the third source: its cut-off reaches the body through the same signal, with the
114
135
  // `X_DRAINING` reason `executeJob` reads to hand the attempt back uncounted.
115
136
  runSignal = createRunSignal([base.signal, heartbeat.signal, options.drain]);
116
137
  const signal = runSignal;
@@ -132,8 +153,9 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
132
153
 
133
154
  return await withSpan(
134
155
  `job.${handle.name}`,
135
- () =>
136
- executeJob({
156
+ () => {
157
+ started = true;
158
+ return executeJob({
137
159
  driver,
138
160
  claimed,
139
161
  handle,
@@ -141,7 +163,8 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
141
163
  ...(options.clock === undefined ? {} : { clock: options.clock }),
142
164
  ...(options.events === undefined ? {} : { events: options.events }),
143
165
  ...(options.refusal === undefined ? {} : { refusal: options.refusal }),
144
- }),
166
+ });
167
+ },
145
168
  {
146
169
  ...(parent === undefined ? {} : { parent }),
147
170
  attributes: {
@@ -152,6 +175,13 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
152
175
  },
153
176
  },
154
177
  );
178
+ } catch (error) {
179
+ // Stopped BEFORE the row goes back, never after: a renewal landing on a handed-back row
180
+ // answers "not yours", which `heartbeat.ts` reports as a lease lost under a running job.
181
+ // Both are idempotent, so the `finally` repeating them costs nothing.
182
+ stopSlotRenewal?.();
183
+ heartbeat.stop();
184
+ return await handedBack(error);
155
185
  } finally {
156
186
  stopSlotRenewal?.();
157
187
  heartbeat.stop();
@@ -0,0 +1,53 @@
1
+ // What one worker has settled, by outcome — the counters `stats()` reports. Split from `worker.ts`
2
+ // at the file-size ceiling; the claim loop counts, this file says what each count means.
3
+
4
+ import type { JobExecution } from './execute';
5
+ import type { WorkerStats } from './worker-types';
6
+
7
+ export type WorkerTallies = Pick<
8
+ WorkerStats,
9
+ 'processed' | 'failed' | 'suspended' | 'deadLettered' | 'interrupted' | 'refused' | 'dropped'
10
+ >;
11
+
12
+ export interface WorkerTally {
13
+ /** One run's execution, counted under its outcome. */
14
+ count(execution: JobExecution): void;
15
+ /** Rows the claim itself buried (`claim-exhausted.ts`), never handed out as work. */
16
+ buried(ended: { readonly deadLettered: number; readonly dropped: number }): void;
17
+ /** A run refused by its concurrency key, body never started (`worker-key-busy.ts`). */
18
+ refused(): void;
19
+ snapshot(): WorkerTallies;
20
+ }
21
+
22
+ export function createWorkerTally(): WorkerTally {
23
+ const tallies = {
24
+ processed: 0,
25
+ failed: 0,
26
+ suspended: 0,
27
+ deadLettered: 0,
28
+ interrupted: 0,
29
+ refused: 0,
30
+ dropped: 0,
31
+ };
32
+ return {
33
+ count(execution) {
34
+ // `retried` is counted as `failed`: the attempt failed, the job did not.
35
+ if (execution.outcome === 'completed') tallies.processed += 1;
36
+ else if (execution.outcome === 'suspended') tallies.suspended += 1;
37
+ else if (execution.outcome === 'retried') tallies.failed += 1;
38
+ else if (execution.outcome === 'interrupted') tallies.interrupted += 1;
39
+ else if (execution.outcome === 'dead-lettered') tallies.deadLettered += 1;
40
+ else if (execution.outcome === 'dropped') tallies.dropped += 1;
41
+ },
42
+ buried(ended) {
43
+ tallies.deadLettered += ended.deadLettered;
44
+ tallies.dropped += ended.dropped;
45
+ },
46
+ refused() {
47
+ tallies.refused += 1;
48
+ },
49
+ snapshot() {
50
+ return { ...tallies };
51
+ },
52
+ };
53
+ }
@@ -1,4 +1,4 @@
1
- // The `worker` role's public contract: what `createWorker` takes, what it hands back, and what
1
+ // The `worker` role's public contract: what `jobWorker` takes, what it hands back, and what
2
2
  // `stats()` reports. Apart from `worker.ts` because that file's job is the claim loop and the
3
3
  // drain, and a contract three files import should not sit under 500 lines of loop.
4
4
 
@@ -13,7 +13,10 @@ export interface WorkerOptions {
13
13
  readonly driver: JobDriver;
14
14
  /** Queues this process serves. Default `['default']`. */
15
15
  readonly queues?: readonly string[];
16
- /** Slots per queue. A number applies to every queue. */
16
+ /**
17
+ * Slots per queue. A number applies to every queue; a table names some, and a queue it leaves out
18
+ * (or no `concurrency` at all) runs at core's `JOBS_CONCURRENCY_DEFAULT`, 8 — 5 before 25.0.0.
19
+ */
17
20
  readonly concurrency?: number | Readonly<Record<string, number>>;
18
21
  readonly limiter?: Limiter;
19
22
  readonly clock?: Clock;
@@ -71,8 +74,8 @@ export interface Worker {
71
74
  /**
72
75
  * Stop claiming, wait for every job this worker holds, close the driver. Unbounded, and it
73
76
  * aborts nothing: a caller that asked wants its work finished. SIGTERM takes the other path —
74
- * the shutdown hooks `start()` registers abort every held run's `ctx.signal` and wait under
75
- * the lifecycle's deadline.
77
+ * it stops claiming and lets held runs finish under the lifecycle's deadline; only at the cut-off
78
+ * (`deadlineAt − margin`) is a still-running `ctx.signal` aborted, and its claim handed back.
76
79
  */
77
80
  stop(reason?: string): Promise<void>;
78
81
  stats(): Promise<WorkerStats>;
package/src/worker.ts CHANGED
@@ -1,22 +1,31 @@
1
1
  // The `worker` role: per-queue pools, a claim loop, lease heartbeats, and a graceful drain on
2
- // SIGTERM (stop claiming -> finish in-flight -> close). A worker that exits mid-job is not a
3
- // bug here — the visibility timeout re-delivers it — but a worker that exits mid-job on EVERY
4
- // deploy turns "at least once" into "always twice", so draining is on by default.
2
+ // SIGTERM (stop claiming -> finish in-flight -> cut off near the deadline -> hand back -> close).
3
+ // A worker that exits mid-job on EVERY deploy turns "at least once" into "always twice", so
4
+ // draining is on by default, and a drain never cancels work it still has budget to finish.
5
5
 
6
6
  import type { ShutdownReason } from '@ultimat3/core';
7
- import { beginWork, logger, onShutdown, recordJob, renderThrowable, uuid } from '@ultimat3/core';
7
+ import {
8
+ beginWork,
9
+ isDraining,
10
+ logger,
11
+ onShutdown,
12
+ recordJob,
13
+ renderThrowable,
14
+ uuidV7,
15
+ } from '@ultimat3/core';
8
16
  import { announceExhausted } from './claim-exhausted';
9
17
  import { nowMs } from './clock';
10
18
  import { createDrainBudget, settleAllBy } from './drain-wait';
11
19
  import type { ClaimedJob, JobRecord } from './driver';
12
20
  import { DEFAULT_QUEUE } from './driver';
13
- import { JobDrainedError } from './errors';
14
21
  import type { JobExecution } from './execute';
15
22
  import { registeredJobs } from './job';
16
- import { createLimiter } from './limits';
23
+ import { concurrencyLimiter } from './limits';
17
24
  import { JOB_OUTCOME_LABELS } from './metrics';
18
25
  import { claimAsks, createAdmission } from './worker-admit';
26
+ import { armWorkerCutoff, type DrainCutoff } from './worker-drain-cutoff';
19
27
  import { assertConcurrencyEnforceable, createFleetSlots } from './worker-fleet-slots';
28
+ import { createHeldRuns } from './worker-held';
20
29
  import type { ClaimLoop } from './worker-loop';
21
30
  import { createClaimLoop } from './worker-loop';
22
31
  import { resolveWorkerTimings } from './worker-options';
@@ -24,18 +33,19 @@ import { createQueueDepthPublisher } from './worker-queue-depth';
24
33
  import type { WorkerRegistration } from './worker-registry';
25
34
  import { reportUnregisteredQueues, startWorkerRegistry } from './worker-registry';
26
35
  import { runClaimedJob } from './worker-run';
36
+ import { createWorkerTally } from './worker-tally';
27
37
  import type { Worker, WorkerOptions, WorkerStats } from './worker-types';
28
38
 
29
39
  export type { Worker, WorkerOptions, WorkerStats } from './worker-types';
30
40
 
31
- export function createWorker(options: WorkerOptions): Worker {
32
- const workerId = options.workerId ?? `worker-${uuid()}`;
41
+ export function jobWorker(options: WorkerOptions): Worker {
42
+ const workerId = options.workerId ?? `worker-${uuidV7()}`;
33
43
  const queues = options.queues ?? [DEFAULT_QUEUE];
34
44
  // Every numeric knob, read and refused in one place — `worker-options.ts` says why a non-finite
35
45
  // one is a refusal rather than a clamp, and carries the slot table's own-key read with it.
36
46
  const { visibilityTimeoutMs, pollIntervalMs, heartbeatIntervalMs, slotsFor } =
37
47
  resolveWorkerTimings(options);
38
- const limiter = options.limiter ?? createLimiter({});
48
+ const limiter = options.limiter ?? concurrencyLimiter({});
39
49
  const driverLeases = options.driver.leases;
40
50
  // `job.concurrency`, held as a row every replica sees. The TTL is the visibility timeout and the
41
51
  // renewal rides the lease heartbeat's interval — `worker-fleet-slots.ts` says why both.
@@ -58,20 +68,21 @@ export function createWorker(options: WorkerOptions): Worker {
58
68
  ...(options.clock === undefined ? {} : { clock: options.clock }),
59
69
  });
60
70
 
61
- const inFlight = new Set<Promise<unknown>>();
71
+ /** The runs this worker holds, each with its claim — what a drain's cut-off hands back. */
72
+ const inFlight = createHeldRuns({ driver: options.driver, workerId });
62
73
  /**
63
74
  * Claim rounds in flight. Jobs land in `inFlight` mid-round, so a drain that waited only on
64
75
  * `inFlight` waited on a set the round it was racing had not finished filling.
65
76
  */
66
77
  const rounds = new Set<Promise<unknown>>();
67
78
  /**
68
- * The drain, as every run this worker starts hears it: composed into each run's `ctx.signal`
69
- * (`worker-run.ts`), aborted by the `accept` hook with a `JobDrainedError`. ONE controller and
70
- * not one per run, because the fact it carries — "this process is going away" — is one fact.
71
- * Replaced with a fresh one when a teardown ends: a controller aborted once stays aborted, and
72
- * a restarted worker would otherwise hand every job it claimed a signal born cancelled.
79
+ * The drain, as every run hears it: composed into each `ctx.signal` (`worker-run.ts`), aborted
80
+ * with a `JobDrainedError` at the CUT-OFF (`worker-drain-cutoff.ts`), never at SIGTERM itself.
81
+ * ONE controller, because "this process is going away" is one fact. Fresh after each teardown:
82
+ * aborted once it stays aborted, and a restarted worker's jobs would be born cancelled.
73
83
  */
74
84
  let drainSignal = new AbortController();
85
+ let cutoff: DrainCutoff | undefined; // armed by the first shutdown to reach this worker
75
86
  /**
76
87
  * The deadline the teardown waits under. `undefined` for a manual `stop()`, bound the moment a
77
88
  * shutdown lands — and bound LATE when that shutdown lands on a teardown already in flight: the
@@ -90,19 +101,7 @@ export function createWorker(options: WorkerOptions): Worker {
90
101
  let releaseShutdownHooks: (() => void)[] = [];
91
102
  /** The teardown in flight, so a second `stop()` joins it instead of running a second one. */
92
103
  let stopping: Promise<void> | undefined;
93
- let processed = 0;
94
- let failed = 0;
95
- let suspended = 0;
96
- let deadLettered = 0;
97
- let interrupted = 0;
98
- let refused = 0;
99
- let dropped = 0;
100
- /**
101
- * The ids this worker holds right now, with how many RUNS hold each — what its registry row
102
- * reports. A count, because one worker can hold a job twice (a claim that lapsed under a body
103
- * still running, then its own re-claim): a set dropped the id when the first run ended.
104
- */
105
- const holding = new Map<string, number>();
104
+ const tally = createWorkerTally();
106
105
  let registration: WorkerRegistration | undefined;
107
106
  /** `queue_depth` and its two siblings, republished on their own interval (`worker-queue-depth.ts`). */
108
107
  const publishQueueDepth = createQueueDepthPublisher({
@@ -122,6 +121,7 @@ export function createWorker(options: WorkerOptions): Worker {
122
121
  workerId,
123
122
  visibilityTimeoutMs,
124
123
  heartbeatIntervalMs,
124
+ pollIntervalMs,
125
125
  drain: drainSignal.signal,
126
126
  ...(options.clock === undefined ? {} : { clock: options.clock }),
127
127
  ...(options.schedule === undefined ? {} : { schedule: options.schedule }),
@@ -177,8 +177,7 @@ export function createWorker(options: WorkerOptions): Worker {
177
177
  workerId,
178
178
  context: options.context,
179
179
  });
180
- deadLettered += ended.deadLettered;
181
- dropped += ended.dropped;
180
+ tally.buried(ended);
182
181
  for (const row of buried) {
183
182
  const label = JOB_OUTCOME_LABELS[row.state === 'failed' ? 'dropped' : 'dead-lettered'];
184
183
  if (label !== null) recordJob(row.queue, label);
@@ -193,7 +192,7 @@ export function createWorker(options: WorkerOptions): Worker {
193
192
  if (admission.kind === 'refused') {
194
193
  // Settled without a body: counted and returned with the pass, the one job this loop
195
194
  // itself finishes.
196
- refused += 1;
195
+ tally.refused();
197
196
  const label = JOB_OUTCOME_LABELS[admission.execution.outcome];
198
197
  if (label !== null) recordJob(queue, label);
199
198
  started.push(Promise.resolve(admission.execution));
@@ -205,15 +204,17 @@ export function createWorker(options: WorkerOptions): Worker {
205
204
  // core's own in-flight wait sits between `accept` and `inflight` and exists for exactly
206
205
  // this. Counted nowhere, the worker had to wait for its own jobs inside a hook.
207
206
  const finishWork = beginWork();
208
- holding.set(job.id, (holding.get(job.id) ?? 0) + 1);
207
+ // Once per run, whichever comes first: the run's own `finally`, or the drain handing its
208
+ // row back while the body is still running (`worker-held.ts`).
209
+ let slotReleased = false;
210
+ const releaseSlot = async (): Promise<void> => {
211
+ if (slotReleased) return;
212
+ slotReleased = true;
213
+ await fleetSlots.release(job.id);
214
+ };
209
215
  const running = runClaimed(job, admission.refusal)
210
216
  .then((execution) => {
211
- if (execution.outcome === 'completed') processed += 1;
212
- else if (execution.outcome === 'suspended') suspended += 1;
213
- else if (execution.outcome === 'retried') failed += 1;
214
- else if (execution.outcome === 'interrupted') interrupted += 1;
215
- else if (execution.outcome === 'dead-lettered') deadLettered += 1;
216
- else if (execution.outcome === 'dropped') dropped += 1;
217
+ tally.count(execution);
217
218
  // The other half of this package's metrics contract: `queue_depth` says how much work
218
219
  // is waiting, `jobs_total` says whether any of it is succeeding. Depth alone cannot
219
220
  // tell a drained queue from a queue nothing ever claimed. Labelled by QUEUE and
@@ -228,9 +229,6 @@ export function createWorker(options: WorkerOptions): Worker {
228
229
  return execution;
229
230
  })
230
231
  .finally(async () => {
231
- const held = (holding.get(job.id) ?? 1) - 1;
232
- if (held === 0) holding.delete(job.id);
233
- else holding.set(job.id, held);
234
232
  lease.release();
235
233
  // The slot is free NOW. A full worker asks the queue for nothing, so its loop backs
236
234
  // off like an idle one — and waiting that out with a backlog behind the job that
@@ -244,24 +242,27 @@ export function createWorker(options: WorkerOptions): Worker {
244
242
  // `finally` is for the one that could, because a lost `finishWork()` is an in-flight
245
243
  // count that never returns to zero and a drain that waits out its whole budget.
246
244
  try {
247
- await fleetSlots.release(job.id);
245
+ await releaseSlot();
248
246
  } finally {
249
247
  finishWork();
250
248
  }
251
249
  });
252
250
 
253
251
  started.push(running);
254
- inFlight.add(running);
252
+ inFlight.add(running, job, async () => {
253
+ await releaseSlot();
254
+ finishWork();
255
+ });
255
256
  // The claim loop no longer awaits these, so this is the one place a rejection is observed:
256
257
  // unobserved it is an unhandled rejection, which on Bun's default is the whole process.
257
258
  // `executeJob` settles the job itself, so reaching here means the driver could not be
258
259
  // told how it ended — the lease will lapse and the queue will deliver it again.
259
260
  void running.then(
260
261
  () => {
261
- inFlight.delete(running);
262
+ inFlight.settle(running);
262
263
  },
263
264
  (error: unknown) => {
264
- inFlight.delete(running);
265
+ inFlight.settle(running);
265
266
  logger.error('jobs.worker.settle-failed', {
266
267
  workerId,
267
268
  job: job.name,
@@ -306,7 +307,7 @@ export function createWorker(options: WorkerOptions): Worker {
306
307
  * whole batch was done. `worker-loop.ts` owns the timer and everything that cuts its wait short.
307
308
  */
308
309
  const loop: ClaimLoop = createClaimLoop({
309
- subject: 'createWorker',
310
+ subject: 'jobWorker',
310
311
  floorMs: pollIntervalMs,
311
312
  ...(options.idlePollMaxMs === undefined ? {} : { ceilingMs: options.idlePollMaxMs }),
312
313
  queues,
@@ -317,21 +318,18 @@ export function createWorker(options: WorkerOptions): Worker {
317
318
  });
318
319
 
319
320
  /**
320
- * The whole of the `accept` phase: stop taking work, tell the work already held, and nothing
321
- * else. Synchronous on purpose — a phase whose job is to be over before the load balancer's next
322
- * health check must not contain a wait, and the hook behind this one is somebody else's "stop
323
- * listening". An abort is synchronous and costs nothing, which is why it belongs HERE and not in
324
- * the teardown: core runs `accept`, then waits out in-flight work (every claimed job is
325
- * `beginWork()`ed) under the same budget, then `close`. Told in `close`, a body that reads
326
- * `ctx.signal` would hear it after the in-flight wait had already spent the whole budget on it —
327
- * which is what happened until 2026-09-07: a job that would have stopped in a second was waited
328
- * on for the full deadline and abandoned there, exactly like one that ignores the signal.
321
+ * The whole of the `accept` phase: stop taking work and ARM the cut-off — nothing else, and no
322
+ * wait: the hook behind this one is somebody else's "stop listening". Armed here and not in
323
+ * `close`, which runs only after core's in-flight wait has spent the budget.
324
+ *
325
+ * The jobs held are NOT told. Until 2026-10-05 this aborted every run at SIGTERM, and a step
326
+ * whose side effect completed during the drain was refused and run again by the next worker.
327
+ * The cut-off (`worker-drain-cutoff.ts`) tells them at `deadlineAt − margin`, then hands back
328
+ * what is still held so the replacement claims it at once, not after the visibility timeout.
329
329
  *
330
- * Only a SHUTDOWN aborts, and only a shutdown binds the budget. A manual `stop()` passes
331
- * nothing: a caller that asked has no budget to spend and wants its work finished, the same
332
- * line `settleAllBy` draws for the wait. The bind comes BEFORE the abort's once-guard and on
333
- * every call, because the second shutdown to reach a worker is the one that finds the abort
334
- * already fired and the teardown already waiting — with no deadline, if the first was manual.
330
+ * Only a SHUTDOWN binds the budget and arms it: a manual `stop()` has no budget and wants its
331
+ * work finished. The bind runs on every call — a SIGTERM landing on a manual stop is what binds
332
+ * the teardown already waiting — and a tighter deadline re-arms through the budget's watch.
335
333
  */
336
334
  const stopAccepting = (shutdown?: ShutdownReason): void => {
337
335
  if (state === 'stopped') return;
@@ -339,13 +337,19 @@ export function createWorker(options: WorkerOptions): Worker {
339
337
  loop.stop();
340
338
  if (shutdown === undefined) return;
341
339
  budget.bind(shutdown.deadlineAt);
342
- if (drainSignal.signal.aborted) return;
340
+ if (cutoff !== undefined) return;
343
341
  logger.info('jobs.worker.drain-signalled', {
344
342
  workerId,
345
343
  signal: shutdown.signal,
346
344
  inFlight: inFlight.size,
347
345
  });
348
- drainSignal.abort(new JobDrainedError({ workerId, signal: shutdown.signal }));
346
+ cutoff = armWorkerCutoff({
347
+ budget,
348
+ workerId,
349
+ signal: shutdown.signal,
350
+ drain: drainSignal,
351
+ held: inFlight,
352
+ });
349
353
  };
350
354
 
351
355
  const teardown = async (reason: string): Promise<void> => {
@@ -360,10 +364,11 @@ export function createWorker(options: WorkerOptions): Worker {
360
364
  // take, and bound by the SIGTERM — whether it arrived before this teardown or lands in the
361
365
  // middle of it. Nothing can kill a body that ignores `ctx.signal`, so an unbounded wait
362
366
  // here is a teardown that never ends: driver never closed, state never past 'draining', and
363
- // the memoized `stopping` every later `stop()` joins never settling. Abandoning costs a
364
- // lapsed lease and a redelivered job — at-least-once, as promised.
367
+ // the memoized `stopping` every later `stop()` joins never settling. `empty()`, not a
368
+ // snapshot: the cut-off's hand-back empties the set under bodies still running, and that
369
+ // ends this wait. What a late round started is handed back below, before the close.
365
370
  const rounded = await settleAllBy([...rounds], budget);
366
- const drained = (await settleAllBy([...inFlight], budget)) && rounded;
371
+ const drained = (await settleAllBy([inFlight.empty()], budget)) && rounded;
367
372
  if (!drained) {
368
373
  logger.warn('jobs.worker.drain-abandoned', {
369
374
  workerId,
@@ -372,6 +377,7 @@ export function createWorker(options: WorkerOptions): Worker {
372
377
  fix: 'raise the drain budget past the slowest job — configureLifecycle({ deadlineMs: 600_000 }) — and set terminationGracePeriodSeconds to at least as many seconds',
373
378
  });
374
379
  }
380
+ if (inFlight.size > 0 && budget.deadlineAt !== undefined) await inFlight.handBackAll();
375
381
  // Before the close, and awaited: the row is deleted through the driver being closed. Under
376
382
  // the SAME budget as the jobs: `forgetWorker` is a statement on the pool that may be the
377
383
  // thing failing, and unbounded it held a SIGTERM teardown open with the driver never closed.
@@ -395,6 +401,8 @@ export function createWorker(options: WorkerOptions): Worker {
395
401
  // A run this drain abandoned still follows the old controller through its own composition;
396
402
  // the next start's jobs must not. Fresh here, in the one place a teardown always reaches —
397
403
  // and the budget with it, or the next manual stop would inherit a deadline already spent.
404
+ cutoff?.dispose();
405
+ cutoff = undefined;
398
406
  drainSignal = new AbortController();
399
407
  budget = createDrainBudget();
400
408
  }
@@ -405,8 +413,8 @@ export function createWorker(options: WorkerOptions): Worker {
405
413
  // waits are bounded and the state is set in a `finally`), so a caller landing after an
406
414
  // abandoned drain gets an answer rather than joining a promise that never settles.
407
415
  if (state === 'stopped') return;
408
- // Before the join, every time: a SIGTERM landing on a manual stop still aborts every held
409
- // run's `ctx.signal` and binds the teardown already waiting to the shutdown's deadline.
416
+ // Before the join, every time: a SIGTERM landing on a manual stop still arms the cut-off and
417
+ // binds the teardown already waiting to the shutdown's deadline.
410
418
  stopAccepting(shutdown);
411
419
  // One teardown, joined rather than repeated: that SIGTERM must wait out the same in-flight
412
420
  // work, not close the driver a second time underneath it. Cleared as it settles, so a worker
@@ -424,6 +432,12 @@ export function createWorker(options: WorkerOptions): Worker {
424
432
  // Only from a standstill. A start mid-drain would put the claim loop back on a driver the
425
433
  // drain is about to close, and stack a second shutdown hook on the one still running.
426
434
  if (state !== 'idle' && state !== 'stopped') return;
435
+ // Nor inside the PROCESS's drain: a boot that reached here after SIGTERM registers hooks the
436
+ // drain is already past, so a worker that claimed now held jobs nothing would ever drain.
437
+ if (isDraining()) {
438
+ logger.warn('jobs.worker.start-refused-draining', { workerId });
439
+ return;
440
+ }
427
441
  // Refused HERE, at the earliest decidable point, and refused rather than logged: an agent
428
442
  // reads "max in-flight runs of THIS job across the fleet", writes `concurrency: 1` on
429
443
  // `rebuildSearchIndex`, ships, and two workers run it on the first deploy — while
@@ -438,7 +452,7 @@ export function createWorker(options: WorkerOptions): Worker {
438
452
  host: options.host,
439
453
  queues,
440
454
  concurrency: queues.reduce((slots, queue) => slots + slotsFor(queue), 0),
441
- inFlight: () => [...holding.keys()],
455
+ inFlight: () => inFlight.ids(),
442
456
  // The visibility timeout, renewed on the heartbeat interval: a killed worker leaves the
443
457
  // registry on exactly the schedule the queue takes its jobs back.
444
458
  ttlMs: visibilityTimeoutMs,
@@ -446,8 +460,8 @@ export function createWorker(options: WorkerOptions): Worker {
446
460
  ...(options.clock === undefined ? {} : { clock: options.clock }),
447
461
  ...(options.schedule === undefined ? {} : { schedule: options.schedule }),
448
462
  });
449
- // TWO hooks, for the two phases that answer two questions. `accept` stops claiming, aborts
450
- // every held run's `ctx.signal` and returns, so every hook behind it — the HTTP server's
463
+ // TWO hooks, for the two phases that answer two questions. `accept` stops claiming, arms
464
+ // the cut-off that tells the held runs near the deadline, and returns, so every hook behind it — the HTTP server's
451
465
  // "stop listening", the sync node's "stop upgrading" — runs while the budget is still whole;
452
466
  // one hook doing both spent all of it in the phase whose whole purpose is to be quick.
453
467
  // `close` waits out what this worker holds and closes the driver, bounded by the deadline
@@ -476,13 +490,7 @@ export function createWorker(options: WorkerOptions): Worker {
476
490
  queues,
477
491
  state,
478
492
  inFlight: inFlight.size,
479
- processed,
480
- failed,
481
- suspended,
482
- deadLettered,
483
- interrupted,
484
- refused,
485
- dropped,
493
+ ...tally.snapshot(),
486
494
  pollDelayMs: loop.delayMs(),
487
495
  queueDepth: [...(await options.driver.stats())],
488
496
  };
@@ -1,89 +0,0 @@
1
- // NATS driver — interface-complete, not implemented. The intended mapping, so the eventual
2
- // implementation has no design decisions left: a JetStream work-queue stream per job queue,
3
- // a durable pull consumer per worker pool (`fetch` == claim, `ack`/`nak` map 1:1),
4
- // `ack_wait` as the visibility timeout, and a KV bucket for step records.
5
-
6
- import { NotImplementedError } from '@ultimat3/core';
7
- import type {
8
- ClaimedJob,
9
- ClaimOptions,
10
- EnqueueRequest,
11
- EnqueueResult,
12
- HeartbeatOptions,
13
- JobDriver,
14
- NackOptions,
15
- QueueStats,
16
- } from './driver';
17
- import type { StepRecord, StepStore } from './steps';
18
-
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
- //
32
- // The nats driver lands in v2; there is no flag that turns this one on.
33
- const FIX =
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';
35
-
36
- const unavailable = (method: string): never => {
37
- throw new NotImplementedError({
38
- cause: `nats jobs driver (${method}) is not implemented`,
39
- fix: FIX,
40
- });
41
- };
42
-
43
- const natsStepStore = (): StepStore => ({
44
- get(_runId: string, _name: string): Promise<StepRecord | undefined> {
45
- return unavailable('steps.get');
46
- },
47
- put(_record: StepRecord): Promise<void> {
48
- return unavailable('steps.put');
49
- },
50
- list(_runId: string): Promise<readonly StepRecord[]> {
51
- return unavailable('steps.list');
52
- },
53
- del(_runId: string, _name: string): Promise<void> {
54
- return unavailable('steps.del');
55
- },
56
- clear(_runId: string): Promise<void> {
57
- return unavailable('steps.clear');
58
- },
59
- });
60
-
61
- export interface NatsDriverOptions {
62
- readonly servers?: readonly string[];
63
- readonly streamPrefix?: string;
64
- }
65
-
66
- export function createNatsDriver(_options: NatsDriverOptions = {}): JobDriver {
67
- return {
68
- name: 'nats',
69
- steps: natsStepStore(),
70
- enqueue(_request: EnqueueRequest): Promise<EnqueueResult> {
71
- return unavailable('enqueue');
72
- },
73
- claim(_options: ClaimOptions): Promise<readonly ClaimedJob[]> {
74
- return unavailable('claim');
75
- },
76
- ack(_jobId: string): Promise<boolean> {
77
- return unavailable('ack');
78
- },
79
- nack(_jobId: string, _options: NackOptions): Promise<boolean> {
80
- return unavailable('nack');
81
- },
82
- heartbeat(_jobId: string, _options: HeartbeatOptions): Promise<boolean> {
83
- return unavailable('heartbeat');
84
- },
85
- stats(): Promise<readonly QueueStats[]> {
86
- return unavailable('stats');
87
- },
88
- };
89
- }