@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/CLAUDE.md
CHANGED
|
@@ -4,7 +4,9 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
4
4
|
|
|
5
5
|
## Boundary
|
|
6
6
|
|
|
7
|
-
- May import: `core`, `schema`, `entity`, `policy`, `cache`, `time
|
|
7
|
+
- May import: `core`, `schema`, `entity`, `policy`, `cache`, `time` — and `db`, for
|
|
8
|
+
`expectedQueryLoop` ONLY: `steps.ts` declares the per-step write one-per-step to the N+1
|
|
9
|
+
detector there, and no client is ever taken from it. Never `http`, `render`, `ui`.
|
|
8
10
|
- Consumers: `action` (`<job>.enqueue`, via the ambient jobs facade), `cli`, `mcp`, `admin`.
|
|
9
11
|
- External deps: none. Postgres access goes through the injected `PgExecutor`.
|
|
10
12
|
|
|
@@ -265,6 +267,16 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
265
267
|
standstill, so start -> stop -> start stacked a second registration retaining a stopped worker's
|
|
266
268
|
driver, and the next process-wide drain ran all of them. `start()` refuses while draining for the
|
|
267
269
|
same reason: a claim loop back on a driver the drain is about to close.
|
|
270
|
+
- **The outbox relay drains in the same two phases, `As of 2026-09`.** It registered NO hook: on
|
|
271
|
+
SIGTERM it went on claiming and publishing through every phase of the drain, and the only thing
|
|
272
|
+
that ever stopped it was `RunningRoles.stop()` in `x dev`'s release path — a caller a signal can
|
|
273
|
+
skip. Two consequences, both silent: a lease stamped on rows nothing on this pod will run (stranded
|
|
274
|
+
for the visibility window), and a row past `driver.enqueue` but short of `markPublished` published
|
|
275
|
+
a second time on the next boot, which the idempotency key collapses only while the first job is
|
|
276
|
+
still live. `accept` clears the interval and returns; `close` awaits the pass in flight under
|
|
277
|
+
`settleAllBy(…, reason.deadlineAt)`; both unregisters come back in the teardown's `finally`. The
|
|
278
|
+
poll timer is `unref`ed for `renewal-timer.ts`'s reason — a 200ms interval refed holds the event
|
|
279
|
+
loop open past every phase and makes SIGKILL the exit.
|
|
268
280
|
- **A claimed job is counted with core's `beginWork()`, so the DRAIN does the waiting**
|
|
269
281
|
(`As of 2026-08-23`). The wait for in-flight jobs belongs to the phase between `accept` and
|
|
270
282
|
`inflight`, which exists for exactly this and is where `@ultimat3/http` already puts a request —
|
|
@@ -281,6 +293,31 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
281
293
|
promises; `jobs.worker.drain-abandoned` names it, with the `configureLifecycle({ deadlineMs })`
|
|
282
294
|
raise as its fix. **A worker always REACHES `'stopped'`**, which is what makes `stop()`'s
|
|
283
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.
|
|
284
321
|
- **One teardown, joined.** `stop()` shares the in-flight teardown promise, so a SIGTERM landing
|
|
285
322
|
on a manual stop waits out the same in-flight jobs instead of closing the driver underneath
|
|
286
323
|
it. The promise is cleared as it settles, so a worker that started again tears down again
|
|
@@ -905,7 +942,8 @@ picture from the other side.
|
|
|
905
942
|
| `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements. Skips a non-job in silence — a module namespace is full of helpers — EXCEPT an `@ultimat3/action` projection (`kind: 'action-job'`), which is `X_ACTION_JOB_UNBRIDGED` |
|
|
906
943
|
| `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
|
|
907
944
|
| `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
|
|
908
|
-
| `outbox.ts` | staging in a `Tx`, the
|
|
945
|
+
| `outbox.ts` | staging in a `Tx`, the store seam, the ambient `JobsFacade` slot |
|
|
946
|
+
| `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks. Split off at `outbox.ts`'s 500-line ceiling |
|
|
909
947
|
| `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
|
|
910
948
|
| `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
|
|
911
949
|
| `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
|
|
@@ -924,7 +962,8 @@ picture from the other side.
|
|
|
924
962
|
| `execute.ts` | `executeJob` — one claimed job run and settled, and the run's deadline/cancel |
|
|
925
963
|
| `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
|
|
926
964
|
| `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
|
|
927
|
-
| `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` |
|
|
928
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 |
|
|
929
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` |
|
|
930
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,6 +621,11 @@ 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
|
|
|
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.
|
|
627
|
+
`drainOnShutdown: false` opts out, for a caller that drives its own teardown.
|
|
628
|
+
|
|
606
629
|
with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
|
|
607
630
|
transactional: `stage()` runs on the CALLER'S connection, never the pool. With nothing installed,
|
|
608
631
|
`jobsFacade()` answers a fallback whose `currentTx` is `() => undefined` and every enqueue
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "19.2
|
|
3
|
+
"version": "19.3.2",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "19.2
|
|
36
|
-
"@ultimat3/
|
|
37
|
-
"@ultimat3/
|
|
38
|
-
"@ultimat3/
|
|
35
|
+
"@ultimat3/core": "19.3.2",
|
|
36
|
+
"@ultimat3/db": "19.3.2",
|
|
37
|
+
"@ultimat3/entity": "19.3.2",
|
|
38
|
+
"@ultimat3/schema": "19.3.2",
|
|
39
|
+
"@ultimat3/time": "19.3.2"
|
|
39
40
|
}
|
|
40
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/driver-memory.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { JobDuplicateError } from './errors';
|
|
|
25
25
|
import type { LeaseStore } from './leases';
|
|
26
26
|
import { createMemoryLeaseStore } from './leases';
|
|
27
27
|
import type { StepStore } from './steps';
|
|
28
|
-
import { createMemoryStepStore } from './steps';
|
|
28
|
+
import { createMemoryStepStore } from './steps-memory';
|
|
29
29
|
|
|
30
30
|
export interface MemoryDriverOptions {
|
|
31
31
|
readonly clock?: Clock;
|
|
@@ -146,7 +146,11 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
146
146
|
// `state !== 'done'`, mirroring `SQL_CANCEL`: a job that already finished has nothing to
|
|
147
147
|
// stop, and cancelling it would rewrite a terminal row an operator is reading as success.
|
|
148
148
|
if (existing === undefined || existing.state === 'done') return Promise.resolve(undefined);
|
|
149
|
-
update
|
|
149
|
+
// `settle`, not `update`: a cancellation RELEASES the claim, and `SQL_CANCEL` writes
|
|
150
|
+
// `visible_at = null, claimed_by = null` with the state. Left stamped, a cancelled row named
|
|
151
|
+
// the worker still holding it and carried that attempt's lease deadline — the pair
|
|
152
|
+
// `x jobs show` prints, and the pair the claim scan reads to decide a row was abandoned.
|
|
153
|
+
settle(jobId, {
|
|
150
154
|
state: 'cancelled',
|
|
151
155
|
...(reason === undefined ? {} : { lastError: reason }),
|
|
152
156
|
});
|
|
@@ -161,7 +165,11 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
161
165
|
leases,
|
|
162
166
|
introspect,
|
|
163
167
|
|
|
164
|
-
|
|
168
|
+
// `async` for the reason `claim`, `list` and `deadLetters` are: `onConflict: 'error'` REJECTS
|
|
169
|
+
// here exactly as the pg driver's does, and a synchronous throw out of a method typed
|
|
170
|
+
// `Promise<…>` is a second answer to one question — caught by different code, and an
|
|
171
|
+
// unhandled exception rather than a settled promise wherever the caller holds the promise.
|
|
172
|
+
async enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
|
|
165
173
|
const existing = liveByKey(request.name, request.idempotencyKey, request.tenantId);
|
|
166
174
|
if (existing !== undefined) {
|
|
167
175
|
if (request.onConflict === 'error') {
|
|
@@ -171,7 +179,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
171
179
|
existingId: existing.id,
|
|
172
180
|
});
|
|
173
181
|
}
|
|
174
|
-
return
|
|
182
|
+
return { id: existing.id, runId: existing.runId, deduped: true };
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
const at = nowMs(clock);
|
|
@@ -194,7 +202,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
194
202
|
...(request.enqueuedBy === undefined ? {} : { enqueuedBy: request.enqueuedBy }),
|
|
195
203
|
};
|
|
196
204
|
jobs.set(record.id, record);
|
|
197
|
-
return
|
|
205
|
+
return { id: record.id, runId: record.runId, deduped: false };
|
|
198
206
|
},
|
|
199
207
|
|
|
200
208
|
// `async`, so an empty queue list REJECTS here exactly as it does on the pg driver: a
|
package/src/driver-nats.ts
CHANGED
|
@@ -16,13 +16,22 @@ import type {
|
|
|
16
16
|
import { JobsNotImplementedError } from './errors';
|
|
17
17
|
import type { StepRecord, StepStore } from './steps';
|
|
18
18
|
|
|
19
|
-
// Names the seam that actually replaces the stub,
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
+
//
|
|
23
32
|
// The nats driver lands in v2; there is no flag that turns this one on.
|
|
24
33
|
const FIX =
|
|
25
|
-
'call setJobDriver(createPgDriver()) at boot instead of this driver,
|
|
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';
|
|
26
35
|
|
|
27
36
|
const unavailable = (method: string): never => {
|
|
28
37
|
throw new JobsNotImplementedError({ feature: `nats jobs driver (${method})`, fix: FIX });
|
package/src/driver-pg.ts
CHANGED
|
@@ -54,8 +54,10 @@ import type { HeldLease, LeaseStore } from './leases';
|
|
|
54
54
|
import type { StepStore } from './steps';
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* The one thing this driver needs from the DB layer, declared structurally so this package
|
|
58
|
-
*
|
|
57
|
+
* The one thing this driver needs from the DB layer, declared structurally so this package needs
|
|
58
|
+
* no database CLIENT: `@ultimat3/db` is imported for `expectedQueryLoop` — the marker that tells
|
|
59
|
+
* the N+1 detector a step write is one-per-step by design (`steps.ts`) — and never for a
|
|
60
|
+
* connection.
|
|
59
61
|
*
|
|
60
62
|
* **Not satisfied by `Bun.sql`** — verified against Bun 1.4.0: `Bun.sql.query` is `undefined`.
|
|
61
63
|
* `Bun.sql` is a tagged template whose positional form is `unsafe`, so a `{ executor: Bun.sql }`
|
package/src/driver-redis.ts
CHANGED
|
@@ -19,13 +19,22 @@ import type {
|
|
|
19
19
|
import { JobsNotImplementedError } from './errors';
|
|
20
20
|
import type { StepRecord, StepStore } from './steps';
|
|
21
21
|
|
|
22
|
-
// Names the seam that actually replaces the stub,
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// Names the seam that actually replaces the stub, and NOTHING ELSE — the two other repairs this
|
|
23
|
+
// line has carried were both unrunnable.
|
|
24
|
+
//
|
|
25
|
+
// NOT `jobs: { driver }` in app.config.ts, which it said until 2026-08-20: the field had no
|
|
26
|
+
// reader anywhere, so that edit repaired nothing and sent the reader back to the same throw. It
|
|
27
|
+
// is deleted now.
|
|
28
|
+
//
|
|
29
|
+
// NOT `x jobs drain --to memory` either, which it said until 2026-09, and that one was a route
|
|
30
|
+
// into data loss: the target is a Map inside the command's own process, so the drain acked every
|
|
31
|
+
// durable row and lost the copy at exit. `x jobs` refuses that value by name now. There is no
|
|
32
|
+
// drain to run in its place, and the reason is in this file — `enqueue` below refuses too, so
|
|
33
|
+
// nothing was ever queued onto this driver and the queue is untouched.
|
|
34
|
+
//
|
|
26
35
|
// The redis driver lands in v2; there is no flag that turns this one on.
|
|
27
36
|
const FIX =
|
|
28
|
-
'call setJobDriver(createPgDriver()) at boot instead of this driver,
|
|
37
|
+
'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';
|
|
29
38
|
|
|
30
39
|
const unavailable = (method: string): never => {
|
|
31
40
|
throw new JobsNotImplementedError({ feature: `redis jobs driver (${method})`, fix: FIX });
|
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
|
@@ -159,6 +159,7 @@ export {
|
|
|
159
159
|
JOB_ERROR_CODES,
|
|
160
160
|
JOB_ERROR_TITLES,
|
|
161
161
|
JobAbortedError,
|
|
162
|
+
JobDrainedError,
|
|
162
163
|
JobDuplicateError,
|
|
163
164
|
JobMaxAttemptsError,
|
|
164
165
|
JobNameTakenError,
|
|
@@ -233,14 +234,11 @@ export type {
|
|
|
233
234
|
MemoryOutboxStore,
|
|
234
235
|
OutboxDeps,
|
|
235
236
|
OutboxRecord,
|
|
236
|
-
OutboxRelay,
|
|
237
237
|
OutboxStore,
|
|
238
|
-
RelayOptions,
|
|
239
238
|
} from './outbox';
|
|
240
239
|
export {
|
|
241
240
|
createJobsFacade,
|
|
242
241
|
createMemoryOutboxStore,
|
|
243
|
-
createOutboxRelay,
|
|
244
242
|
enqueueInTx,
|
|
245
243
|
jobsFacade,
|
|
246
244
|
resetJobsFacade,
|
|
@@ -251,6 +249,8 @@ export {
|
|
|
251
249
|
export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
|
|
252
250
|
export type { PgOutboxOptions } from './outbox-pg';
|
|
253
251
|
export { createPgOutboxStore } from './outbox-pg';
|
|
252
|
+
export type { OutboxRelay, RelayOptions } from './outbox-relay';
|
|
253
|
+
export { createOutboxRelay } from './outbox-relay';
|
|
254
254
|
export type {
|
|
255
255
|
PurgeDefinition,
|
|
256
256
|
PurgeInput,
|
|
@@ -290,7 +290,6 @@ export type {
|
|
|
290
290
|
WaitForEventOptions,
|
|
291
291
|
} from './steps';
|
|
292
292
|
export {
|
|
293
|
-
createMemoryStepStore,
|
|
294
293
|
createStepRunner,
|
|
295
294
|
isStepStatus,
|
|
296
295
|
isStepSuspension,
|
|
@@ -298,6 +297,7 @@ export {
|
|
|
298
297
|
STEP_STATUSES,
|
|
299
298
|
StepSuspension,
|
|
300
299
|
} from './steps';
|
|
300
|
+
export { createMemoryStepStore } from './steps-memory';
|
|
301
301
|
export type {
|
|
302
302
|
CatchUpPolicy,
|
|
303
303
|
TaskDefinition,
|
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', {
|