@ultimat3/jobs 19.2.0 → 19.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +42 -3
- package/README.md +23 -0
- package/package.json +6 -5
- package/src/drain-wait.ts +75 -25
- package/src/driver-memory.ts +13 -5
- package/src/driver-nats.ts +14 -5
- package/src/driver-pg.ts +4 -2
- package/src/driver-redis.ts +14 -5
- package/src/errors.ts +28 -5
- package/src/execute.ts +43 -1
- package/src/index.ts +4 -4
- package/src/metrics.ts +18 -0
- package/src/outbox-relay.ts +221 -0
- package/src/outbox.ts +1 -138
- package/src/scheduler.ts +36 -4
- package/src/steps-memory.ts +39 -0
- package/src/steps.ts +18 -35
- package/src/webhook.ts +4 -4
- package/src/worker-run.ts +10 -2
- package/src/worker-types.ts +56 -0
- package/src/worker.ts +88 -85
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 {
|
|
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
|
|
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
|
|
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,
|
|
372
|
-
* a phase whose job is to be over before the load balancer's next
|
|
373
|
-
* a wait, and the hook behind this one is somebody else's "stop
|
|
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
|
|
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
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
const
|
|
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',
|
|
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
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
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
|
|
457
|
-
// returns, so every hook behind it — the HTTP server's
|
|
458
|
-
// "stop upgrading" — runs while the budget is still whole;
|
|
459
|
-
// it in the phase whose whole purpose is to be quick.
|
|
460
|
-
// holds and closes the driver, bounded by the deadline
|
|
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, {
|
|
468
|
-
|
|
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
|
},
|