@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 +27 -1
- package/README.md +21 -2
- package/package.json +6 -6
- package/src/drain-wait.ts +75 -25
- package/src/errors.ts +28 -5
- package/src/execute.ts +43 -1
- package/src/index.ts +1 -0
- package/src/metrics.ts +18 -0
- package/src/worker-run.ts +10 -2
- package/src/worker-types.ts +56 -0
- package/src/worker.ts +88 -85
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
|
-
|
|
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.
|
|
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.
|
|
36
|
-
"@ultimat3/db": "19.3.
|
|
37
|
-
"@ultimat3/entity": "19.3.
|
|
38
|
-
"@ultimat3/schema": "19.3.
|
|
39
|
-
"@ultimat3/time": "19.3.
|
|
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
|
-
*
|
|
10
|
-
* `ShutdownReason.deadlineAt` is measured on)
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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 `
|
|
43
|
-
* `JobAbortedError` below throw them; jobs keeps no title for
|
|
44
|
-
* used to hold was a second title that nothing would have failed
|
|
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
|
|
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
|
-
|
|
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
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
|
-
|
|
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 {
|
|
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
|
},
|