@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/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
  },