@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.
- package/CLAUDE.md +25 -27
- package/README.md +50 -34
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +1 -1
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +3 -3
- package/src/driver-memory.ts +14 -17
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +1 -1
- package/src/driver-pg.ts +18 -18
- package/src/driver.ts +9 -15
- package/src/errors-concurrency.ts +1 -1
- package/src/errors.ts +5 -5
- package/src/events-pg.ts +4 -4
- package/src/events.ts +6 -6
- package/src/execute.ts +46 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +37 -97
- package/src/inspect-operator.ts +1 -1
- package/src/inspect.ts +1 -1
- package/src/job.ts +1 -1
- package/src/leases.ts +1 -1
- package/src/limits.ts +13 -8
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +5 -5
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +13 -13
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +16 -14
- package/src/scheduler-state.ts +2 -2
- package/src/scheduler.ts +5 -5
- package/src/steps-memory.ts +2 -2
- package/src/steps.ts +35 -7
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +64 -118
- package/src/worker-admit.ts +7 -3
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +5 -2
- package/src/worker-held.ts +100 -0
- package/src/worker-options.ts +19 -15
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +85 -77
- package/src/driver-nats.ts +0 -89
- 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`
|
|
36
|
-
* the
|
|
37
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
package/src/worker-types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The `worker` role's public contract: 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
|
-
/**
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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 ->
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
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 {
|
|
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
|
|
32
|
-
const workerId = options.workerId ?? `worker-${
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
69
|
-
* (`worker-
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
262
|
+
inFlight.settle(running);
|
|
262
263
|
},
|
|
263
264
|
(error: unknown) => {
|
|
264
|
-
inFlight.
|
|
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: '
|
|
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
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* `
|
|
327
|
-
*
|
|
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
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
364
|
-
//
|
|
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([
|
|
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
|
|
409
|
-
//
|
|
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: () =>
|
|
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,
|
|
450
|
-
//
|
|
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
|
-
|
|
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
|
};
|
package/src/driver-nats.ts
DELETED
|
@@ -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
|
-
}
|