@ultimat3/jobs 19.1.3 → 19.3.1
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 +15 -2
- package/README.md +4 -0
- package/package.json +6 -5
- 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/index.ts +3 -4
- 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/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 —
|
|
@@ -905,7 +917,8 @@ picture from the other side.
|
|
|
905
917
|
| `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
918
|
| `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
|
|
907
919
|
| `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
|
|
908
|
-
| `outbox.ts` | staging in a `Tx`, the
|
|
920
|
+
| `outbox.ts` | staging in a `Tx`, the store seam, the ambient `JobsFacade` slot |
|
|
921
|
+
| `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
922
|
| `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
|
|
910
923
|
| `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
|
|
911
924
|
| `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
|
package/README.md
CHANGED
|
@@ -603,6 +603,10 @@ things have to be true in a process:
|
|
|
603
603
|
| the facade is installed | `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` |
|
|
604
604
|
| the relay is running | `createOutboxRelay({ store, driver }).start()` |
|
|
605
605
|
|
|
606
|
+
`start()` registers the same two shutdown hooks `createWorker` does — `accept` stops polling,
|
|
607
|
+
`close` waits out the pass in flight under the drain's deadline — and `stop()` hands both back.
|
|
608
|
+
`drainOnShutdown: false` opts out, for a caller that drives its own teardown.
|
|
609
|
+
|
|
606
610
|
with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
|
|
607
611
|
transactional: `stage()` runs on the CALLER'S connection, never the pool. With nothing installed,
|
|
608
612
|
`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.1
|
|
3
|
+
"version": "19.3.1",
|
|
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.1
|
|
36
|
-
"@ultimat3/
|
|
37
|
-
"@ultimat3/
|
|
38
|
-
"@ultimat3/
|
|
35
|
+
"@ultimat3/core": "19.3.1",
|
|
36
|
+
"@ultimat3/db": "19.3.1",
|
|
37
|
+
"@ultimat3/entity": "19.3.1",
|
|
38
|
+
"@ultimat3/schema": "19.3.1",
|
|
39
|
+
"@ultimat3/time": "19.3.1"
|
|
39
40
|
}
|
|
40
41
|
}
|
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/index.ts
CHANGED
|
@@ -233,14 +233,11 @@ export type {
|
|
|
233
233
|
MemoryOutboxStore,
|
|
234
234
|
OutboxDeps,
|
|
235
235
|
OutboxRecord,
|
|
236
|
-
OutboxRelay,
|
|
237
236
|
OutboxStore,
|
|
238
|
-
RelayOptions,
|
|
239
237
|
} from './outbox';
|
|
240
238
|
export {
|
|
241
239
|
createJobsFacade,
|
|
242
240
|
createMemoryOutboxStore,
|
|
243
|
-
createOutboxRelay,
|
|
244
241
|
enqueueInTx,
|
|
245
242
|
jobsFacade,
|
|
246
243
|
resetJobsFacade,
|
|
@@ -251,6 +248,8 @@ export {
|
|
|
251
248
|
export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
|
|
252
249
|
export type { PgOutboxOptions } from './outbox-pg';
|
|
253
250
|
export { createPgOutboxStore } from './outbox-pg';
|
|
251
|
+
export type { OutboxRelay, RelayOptions } from './outbox-relay';
|
|
252
|
+
export { createOutboxRelay } from './outbox-relay';
|
|
254
253
|
export type {
|
|
255
254
|
PurgeDefinition,
|
|
256
255
|
PurgeInput,
|
|
@@ -290,7 +289,6 @@ export type {
|
|
|
290
289
|
WaitForEventOptions,
|
|
291
290
|
} from './steps';
|
|
292
291
|
export {
|
|
293
|
-
createMemoryStepStore,
|
|
294
292
|
createStepRunner,
|
|
295
293
|
isStepStatus,
|
|
296
294
|
isStepSuspension,
|
|
@@ -298,6 +296,7 @@ export {
|
|
|
298
296
|
STEP_STATUSES,
|
|
299
297
|
StepSuspension,
|
|
300
298
|
} from './steps';
|
|
299
|
+
export { createMemoryStepStore } from './steps-memory';
|
|
301
300
|
export type {
|
|
302
301
|
CatchUpPolicy,
|
|
303
302
|
TaskDefinition,
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// The outbox relay: the loop that turns a COMMITTED `x_outbox` row into a queued job. Split from
|
|
2
|
+
// `outbox.ts` — that file owns the store seam, the staging and the facade; this one owns a timer, a
|
|
3
|
+
// pass and the process drain around them.
|
|
4
|
+
//
|
|
5
|
+
// At-least-once by construction: publish, THEN mark published. A crash between the two re-publishes,
|
|
6
|
+
// which the idempotency key collapses — the opposite order would lose jobs.
|
|
7
|
+
|
|
8
|
+
import { assert, logger, onShutdown, renderThrowable } from '@ultimat3/core';
|
|
9
|
+
import { nowMs } from './clock';
|
|
10
|
+
import { settleAllBy } from './drain-wait';
|
|
11
|
+
import type { OutboxDeps } from './outbox';
|
|
12
|
+
|
|
13
|
+
export interface RelayOptions extends OutboxDeps {
|
|
14
|
+
readonly batchSize?: number;
|
|
15
|
+
readonly intervalMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Register the two shutdown hooks. `false` only for a relay whose caller drives the teardown
|
|
18
|
+
* itself — a test, or a script that owns the process. The same knob `WorkerOptions` carries, and
|
|
19
|
+
* the default is the same: a loop nobody stops on SIGTERM keeps leasing rows this pod will never
|
|
20
|
+
* publish.
|
|
21
|
+
*/
|
|
22
|
+
readonly drainOnShutdown?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OutboxRelay {
|
|
26
|
+
/** One pass. Returns how many rows were published. Call it directly in tests. */
|
|
27
|
+
tick(): Promise<number>;
|
|
28
|
+
start(): void;
|
|
29
|
+
/**
|
|
30
|
+
* Stop polling and WAIT OUT the pass in flight, the way `worker.stop()` waits out its rounds and
|
|
31
|
+
* `scheduler.stop()` its dispatch. A pass is a publish followed by a `markPublished`, and a
|
|
32
|
+
* caller that returned between the two closed the database under the row it was about to mark:
|
|
33
|
+
* re-published next boot at best, a rejection against a closed pool at worst.
|
|
34
|
+
*/
|
|
35
|
+
stop(deadlineAt?: number): Promise<void>;
|
|
36
|
+
pending(): Promise<number>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* At-least-once by construction: publish, THEN mark published. A crash between the two
|
|
41
|
+
* re-publishes, which the idempotency key collapses — the opposite order would lose jobs.
|
|
42
|
+
*/
|
|
43
|
+
export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
44
|
+
const batchSize = options.batchSize ?? 100;
|
|
45
|
+
const intervalMs = options.intervalMs ?? 200;
|
|
46
|
+
// Refused, never clamped — `worker-options.ts` carries the reason. `setInterval(fn, NaN)` reads
|
|
47
|
+
// the delay as 0 and `claim(NaN)` slices `(0, NaN)`: a relay that spins and publishes nothing.
|
|
48
|
+
assert(
|
|
49
|
+
Number.isSafeInteger(batchSize) && batchSize >= 1,
|
|
50
|
+
`createOutboxRelay batchSize is ${String(batchSize)} — a batch is a whole number of staged rows, at least one`,
|
|
51
|
+
'pass a finite batchSize to createOutboxRelay(...), or omit it for the default 100',
|
|
52
|
+
);
|
|
53
|
+
assert(
|
|
54
|
+
Number.isFinite(intervalMs) && intervalMs >= 0,
|
|
55
|
+
`createOutboxRelay intervalMs is ${String(intervalMs)}, which setInterval reads as 0 — the relay would spin, not poll`,
|
|
56
|
+
'pass a finite intervalMs to createOutboxRelay(...), or omit it for the default 200',
|
|
57
|
+
);
|
|
58
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
59
|
+
let running = false;
|
|
60
|
+
/** The pass in flight, so `stop()` joins it instead of returning underneath it. */
|
|
61
|
+
let pass: Promise<void> | undefined;
|
|
62
|
+
/** `'draining'` is the state in which a timer that somehow fires still claims nothing. */
|
|
63
|
+
let state: 'idle' | 'running' | 'draining' | 'stopped' = 'idle';
|
|
64
|
+
let releaseShutdownHooks: (() => void)[] = [];
|
|
65
|
+
let stopping: Promise<void> | undefined;
|
|
66
|
+
|
|
67
|
+
const tick = async (): Promise<number> => {
|
|
68
|
+
const batch = await options.store.claim(batchSize);
|
|
69
|
+
let published = 0;
|
|
70
|
+
for (const record of batch) {
|
|
71
|
+
try {
|
|
72
|
+
await options.driver.enqueue({
|
|
73
|
+
name: record.job,
|
|
74
|
+
queue: record.queue,
|
|
75
|
+
input: record.input,
|
|
76
|
+
idempotencyKey: record.idempotencyKey,
|
|
77
|
+
maxAttempts: record.maxAttempts,
|
|
78
|
+
runAt: record.runAt,
|
|
79
|
+
...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
|
|
80
|
+
...(record.traceparent === undefined ? {} : { traceparent: record.traceparent }),
|
|
81
|
+
...(record.enqueuedBy === undefined ? {} : { enqueuedBy: record.enqueuedBy }),
|
|
82
|
+
});
|
|
83
|
+
// The claim's own token goes back with the mark. Without it a relay whose lease lapsed
|
|
84
|
+
// mid-stall retires a row the relay that reclaimed it has not published yet — the row is
|
|
85
|
+
// gone and nothing publishes it.
|
|
86
|
+
await options.store.markPublished(record.id, nowMs(options.clock), record.claimedBy);
|
|
87
|
+
published += 1;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// STOP the batch. `claim()` returns rows in `staged_at` order and the loop used to log
|
|
90
|
+
// and continue, which published every LATER row past the one that failed — so an app
|
|
91
|
+
// that stages `createInvoice` then `chargeCard` in one transaction could have the charge
|
|
92
|
+
// run first. The row stays unpublished and the next tick starts again from it; a
|
|
93
|
+
// permanently poisoned row wedges its queue, which is visible in `pending()` and is the
|
|
94
|
+
// correct trade against silently reordering committed work.
|
|
95
|
+
logger.warn('jobs.outbox.publish-failed', {
|
|
96
|
+
job: record.job,
|
|
97
|
+
id: record.id,
|
|
98
|
+
published,
|
|
99
|
+
remaining: batch.length - published,
|
|
100
|
+
error: renderThrowable(error),
|
|
101
|
+
});
|
|
102
|
+
// Hand the rest of the batch back rather than sit on a claim nobody is publishing. The
|
|
103
|
+
// claim is a lease now, so without this a single pool timeout parks every committed row
|
|
104
|
+
// behind it for the whole lease window instead of for one poll interval.
|
|
105
|
+
await options.store.release?.(
|
|
106
|
+
batch.slice(published).map((row) => row.id),
|
|
107
|
+
record.claimedBy,
|
|
108
|
+
);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return published;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The `accept` phase, and all of it: flip the state, clear the timer, return. Every hook behind
|
|
117
|
+
* this one — `@ultimat3/http`'s "stop listening", the sync node's "stop upgrading" — then runs
|
|
118
|
+
* with the whole budget still in hand, which is what a wait parked here costs them.
|
|
119
|
+
*/
|
|
120
|
+
const stopAccepting = (): void => {
|
|
121
|
+
if (timer !== undefined) clearInterval(timer);
|
|
122
|
+
timer = undefined;
|
|
123
|
+
if (state === 'running') state = 'draining';
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const teardown = async (deadlineAt?: number): Promise<void> => {
|
|
127
|
+
stopAccepting();
|
|
128
|
+
try {
|
|
129
|
+
// The pass in flight is a `driver.enqueue` and the `markPublished` behind it. Abandoning
|
|
130
|
+
// between the two republishes the row on the next boot — which the idempotency key collapses
|
|
131
|
+
// only while the first job is still live — so it is waited out here, in `close`, under the
|
|
132
|
+
// deadline the hook was handed. `undefined` on a manual `stop()`: a caller that asked has no
|
|
133
|
+
// budget to spend, and nothing else in this process is waiting on the answer.
|
|
134
|
+
const settled = await settleAllBy(pass === undefined ? [] : [pass], deadlineAt);
|
|
135
|
+
if (!settled) {
|
|
136
|
+
logger.warn('jobs.outbox.drain-abandoned', {
|
|
137
|
+
fix: 'raise the drain budget past one publish — configureLifecycle({ deadlineMs: 60_000 }) — and set terminationGracePeriodSeconds to at least as many seconds',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
// Whatever the wait did, this relay is done, and the hooks go back. One left registered
|
|
142
|
+
// publishes through a stopped relay on the next process-wide drain — against a driver
|
|
143
|
+
// already closed — and keeps this closure, its store and its driver alive with it.
|
|
144
|
+
state = 'stopped';
|
|
145
|
+
for (const release of releaseShutdownHooks) release();
|
|
146
|
+
releaseShutdownHooks = [];
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const stop = async (deadlineAt?: number): Promise<void> => {
|
|
151
|
+
// Answered immediately once this relay is done: the teardown always REACHES 'stopped' (its
|
|
152
|
+
// wait is bounded on the SIGTERM path and the state is set in a `finally`), so a caller
|
|
153
|
+
// landing after an abandoned drain gets an answer rather than joining a settled lifetime ago.
|
|
154
|
+
if (state === 'stopped') return;
|
|
155
|
+
// One teardown, joined rather than repeated: a SIGTERM landing on a manual stop waits out the
|
|
156
|
+
// same pass instead of returning underneath it. Cleared as it settles, so a relay started
|
|
157
|
+
// again stops again rather than joining a promise that settled a lifetime ago.
|
|
158
|
+
stopping ??= teardown(deadlineAt).finally(() => {
|
|
159
|
+
stopping = undefined;
|
|
160
|
+
});
|
|
161
|
+
await stopping;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
tick,
|
|
166
|
+
start() {
|
|
167
|
+
if (timer !== undefined) return;
|
|
168
|
+
// Only from a standstill, the worker's rule: a start mid-drain would re-arm the poll on a
|
|
169
|
+
// store the drain is about to leave, and stack a second pair of hooks on the one still
|
|
170
|
+
// running — the first pair's unregisters are held in a single slot and would be dropped.
|
|
171
|
+
if (state === 'draining') return;
|
|
172
|
+
state = 'running';
|
|
173
|
+
// TWO hooks, for the two phases — the shape `worker.ts` and `scheduler.ts` hold. This loop
|
|
174
|
+
// had NONE: on SIGTERM it went on claiming and publishing through every phase, stamping
|
|
175
|
+
// leases on rows nothing on this pod would run, and `RunningRoles.stop()` was the only thing
|
|
176
|
+
// that ever stopped it — a caller a signal can skip entirely.
|
|
177
|
+
//
|
|
178
|
+
// Both unregisters are kept, never discarded: the teardown hands them back, so
|
|
179
|
+
// start -> stop -> start holds one pair rather than one per start.
|
|
180
|
+
if (options.drainOnShutdown !== false) {
|
|
181
|
+
releaseShutdownHooks = [
|
|
182
|
+
onShutdown('jobs.outbox.accept', stopAccepting, { phase: 'accept' }),
|
|
183
|
+
onShutdown('jobs.outbox', (reason) => stop(reason.deadlineAt), { phase: 'close' }),
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
timer = setInterval(() => {
|
|
187
|
+
// Re-read on every tick: "stop claiming" means this timer too, and a callback already on
|
|
188
|
+
// the event loop when the interval was cleared must not open a claim behind the drain.
|
|
189
|
+
if (running || state !== 'running') return;
|
|
190
|
+
running = true;
|
|
191
|
+
// `.catch` before `.finally`, the shape every other loop in this package uses. `tick()`
|
|
192
|
+
// guards each publish but not `store.claim()` — one pool timeout during a failover
|
|
193
|
+
// rejects here unobserved, and Bun's default for an unhandled rejection is to end the
|
|
194
|
+
// process, taking every staged, unpublished row with it.
|
|
195
|
+
//
|
|
196
|
+
// Kept rather than discarded, because `stop()` awaits exactly this chain: the publish and
|
|
197
|
+
// the `markPublished` behind it are one pass, and a teardown that returned between them
|
|
198
|
+
// closed the database under the row it was about to mark. The chain carries its own
|
|
199
|
+
// `catch`, so a caller that does not await still gets no unhandled rejection.
|
|
200
|
+
pass = tick()
|
|
201
|
+
.then((): void => undefined)
|
|
202
|
+
.catch((error: unknown) => {
|
|
203
|
+
logger.error('jobs.outbox.tick-failed', {
|
|
204
|
+
error: renderThrowable(error),
|
|
205
|
+
});
|
|
206
|
+
})
|
|
207
|
+
.finally(() => {
|
|
208
|
+
running = false;
|
|
209
|
+
pass = undefined;
|
|
210
|
+
});
|
|
211
|
+
}, intervalMs);
|
|
212
|
+
// Never the thing keeping a drained process alive — the rule `renewal-timer.ts` and
|
|
213
|
+
// `lifecycle-deadline.ts` both state for their own timers. A poll every 200ms holds the
|
|
214
|
+
// event loop open past every phase of the shutdown, and the kubelet's SIGKILL becomes the
|
|
215
|
+
// exit; the hooks above are what stop this loop, not the process refusing to end.
|
|
216
|
+
timer.unref?.();
|
|
217
|
+
},
|
|
218
|
+
stop,
|
|
219
|
+
pending: () => options.store.pendingCount(),
|
|
220
|
+
};
|
|
221
|
+
}
|
package/src/outbox.ts
CHANGED
|
@@ -24,14 +24,7 @@
|
|
|
24
24
|
// test and `x dev` must enqueue with nothing wired — but it is a fallback, not the guarantee.
|
|
25
25
|
|
|
26
26
|
import type { Clock } from '@ultimat3/core';
|
|
27
|
-
import {
|
|
28
|
-
assert,
|
|
29
|
-
currentSpanContext,
|
|
30
|
-
logger,
|
|
31
|
-
renderThrowable,
|
|
32
|
-
traceparent,
|
|
33
|
-
uuid,
|
|
34
|
-
} from '@ultimat3/core';
|
|
27
|
+
import { currentSpanContext, traceparent, uuid } from '@ultimat3/core';
|
|
35
28
|
import type { Tx } from '@ultimat3/entity';
|
|
36
29
|
import { nowMs } from './clock';
|
|
37
30
|
import type { EnqueueResult, JobDriver } from './driver';
|
|
@@ -364,136 +357,6 @@ export function resetJobsFacade(): void {
|
|
|
364
357
|
ambient = undefined;
|
|
365
358
|
}
|
|
366
359
|
|
|
367
|
-
export interface RelayOptions extends OutboxDeps {
|
|
368
|
-
readonly batchSize?: number;
|
|
369
|
-
readonly intervalMs?: number;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
export interface OutboxRelay {
|
|
373
|
-
/** One pass. Returns how many rows were published. Call it directly in tests. */
|
|
374
|
-
tick(): Promise<number>;
|
|
375
|
-
start(): void;
|
|
376
|
-
/**
|
|
377
|
-
* Stop polling and WAIT OUT the pass in flight, the way `worker.stop()` waits out its rounds and
|
|
378
|
-
* `scheduler.stop()` its dispatch. A pass is a publish followed by a `markPublished`, and a
|
|
379
|
-
* caller that returned between the two closed the database under the row it was about to mark:
|
|
380
|
-
* re-published next boot at best, a rejection against a closed pool at worst.
|
|
381
|
-
*/
|
|
382
|
-
stop(): Promise<void>;
|
|
383
|
-
pending(): Promise<number>;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* At-least-once by construction: publish, THEN mark published. A crash between the two
|
|
388
|
-
* re-publishes, which the idempotency key collapses — the opposite order would lose jobs.
|
|
389
|
-
*/
|
|
390
|
-
export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
391
|
-
const batchSize = options.batchSize ?? 100;
|
|
392
|
-
const intervalMs = options.intervalMs ?? 200;
|
|
393
|
-
// Refused, never clamped — `worker-options.ts` carries the reason. `setInterval(fn, NaN)` reads
|
|
394
|
-
// the delay as 0 and `claim(NaN)` slices `(0, NaN)`: a relay that spins and publishes nothing.
|
|
395
|
-
assert(
|
|
396
|
-
Number.isSafeInteger(batchSize) && batchSize >= 1,
|
|
397
|
-
`createOutboxRelay batchSize is ${String(batchSize)} — a batch is a whole number of staged rows, at least one`,
|
|
398
|
-
'pass a finite batchSize to createOutboxRelay(...), or omit it for the default 100',
|
|
399
|
-
);
|
|
400
|
-
assert(
|
|
401
|
-
Number.isFinite(intervalMs) && intervalMs >= 0,
|
|
402
|
-
`createOutboxRelay intervalMs is ${String(intervalMs)}, which setInterval reads as 0 — the relay would spin, not poll`,
|
|
403
|
-
'pass a finite intervalMs to createOutboxRelay(...), or omit it for the default 200',
|
|
404
|
-
);
|
|
405
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
406
|
-
let running = false;
|
|
407
|
-
/** The pass in flight, so `stop()` joins it instead of returning underneath it. */
|
|
408
|
-
let pass: Promise<void> | undefined;
|
|
409
|
-
|
|
410
|
-
const tick = async (): Promise<number> => {
|
|
411
|
-
const batch = await options.store.claim(batchSize);
|
|
412
|
-
let published = 0;
|
|
413
|
-
for (const record of batch) {
|
|
414
|
-
try {
|
|
415
|
-
await options.driver.enqueue({
|
|
416
|
-
name: record.job,
|
|
417
|
-
queue: record.queue,
|
|
418
|
-
input: record.input,
|
|
419
|
-
idempotencyKey: record.idempotencyKey,
|
|
420
|
-
maxAttempts: record.maxAttempts,
|
|
421
|
-
runAt: record.runAt,
|
|
422
|
-
...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
|
|
423
|
-
...(record.traceparent === undefined ? {} : { traceparent: record.traceparent }),
|
|
424
|
-
...(record.enqueuedBy === undefined ? {} : { enqueuedBy: record.enqueuedBy }),
|
|
425
|
-
});
|
|
426
|
-
// The claim's own token goes back with the mark. Without it a relay whose lease lapsed
|
|
427
|
-
// mid-stall retires a row the relay that reclaimed it has not published yet — the row is
|
|
428
|
-
// gone and nothing publishes it.
|
|
429
|
-
await options.store.markPublished(record.id, nowMs(options.clock), record.claimedBy);
|
|
430
|
-
published += 1;
|
|
431
|
-
} catch (error) {
|
|
432
|
-
// STOP the batch. `claim()` returns rows in `staged_at` order and the loop used to log
|
|
433
|
-
// and continue, which published every LATER row past the one that failed — so an app
|
|
434
|
-
// that stages `createInvoice` then `chargeCard` in one transaction could have the charge
|
|
435
|
-
// run first. The row stays unpublished and the next tick starts again from it; a
|
|
436
|
-
// permanently poisoned row wedges its queue, which is visible in `pending()` and is the
|
|
437
|
-
// correct trade against silently reordering committed work.
|
|
438
|
-
logger.warn('jobs.outbox.publish-failed', {
|
|
439
|
-
job: record.job,
|
|
440
|
-
id: record.id,
|
|
441
|
-
published,
|
|
442
|
-
remaining: batch.length - published,
|
|
443
|
-
error: renderThrowable(error),
|
|
444
|
-
});
|
|
445
|
-
// Hand the rest of the batch back rather than sit on a claim nobody is publishing. The
|
|
446
|
-
// claim is a lease now, so without this a single pool timeout parks every committed row
|
|
447
|
-
// behind it for the whole lease window instead of for one poll interval.
|
|
448
|
-
await options.store.release?.(
|
|
449
|
-
batch.slice(published).map((row) => row.id),
|
|
450
|
-
record.claimedBy,
|
|
451
|
-
);
|
|
452
|
-
break;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return published;
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
return {
|
|
459
|
-
tick,
|
|
460
|
-
start() {
|
|
461
|
-
if (timer !== undefined) return;
|
|
462
|
-
timer = setInterval(() => {
|
|
463
|
-
if (running) return;
|
|
464
|
-
running = true;
|
|
465
|
-
// `.catch` before `.finally`, the shape every other loop in this package uses. `tick()`
|
|
466
|
-
// guards each publish but not `store.claim()` — one pool timeout during a failover
|
|
467
|
-
// rejects here unobserved, and Bun's default for an unhandled rejection is to end the
|
|
468
|
-
// process, taking every staged, unpublished row with it.
|
|
469
|
-
//
|
|
470
|
-
// Kept rather than discarded, because `stop()` awaits exactly this chain: the publish and
|
|
471
|
-
// the `markPublished` behind it are one pass, and a teardown that returned between them
|
|
472
|
-
// closed the database under the row it was about to mark. The chain carries its own
|
|
473
|
-
// `catch`, so a caller that does not await still gets no unhandled rejection.
|
|
474
|
-
pass = tick()
|
|
475
|
-
.then((): void => undefined)
|
|
476
|
-
.catch((error: unknown) => {
|
|
477
|
-
logger.error('jobs.outbox.tick-failed', {
|
|
478
|
-
error: renderThrowable(error),
|
|
479
|
-
});
|
|
480
|
-
})
|
|
481
|
-
.finally(() => {
|
|
482
|
-
running = false;
|
|
483
|
-
pass = undefined;
|
|
484
|
-
});
|
|
485
|
-
}, intervalMs);
|
|
486
|
-
},
|
|
487
|
-
async stop() {
|
|
488
|
-
if (timer !== undefined) clearInterval(timer);
|
|
489
|
-
timer = undefined;
|
|
490
|
-
// Awaited AFTER the interval is cleared, so no further pass can start behind this one.
|
|
491
|
-
await pass;
|
|
492
|
-
},
|
|
493
|
-
pending: () => options.store.pendingCount(),
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
|
|
497
360
|
// The outbox's SQL moved to `driver-pg-sql.ts`, where every statement this package runs lives —
|
|
498
361
|
// and, more to the point, where `SQL_JOBS_TABLE` is: `x_outbox` was declared here, in a constant
|
|
499
362
|
// no boot code applied, which is the whole reason the outbox was documented and never created.
|
package/src/scheduler.ts
CHANGED
|
@@ -139,8 +139,10 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
139
139
|
resolveCron(handle.cron, { tz: handle.tz, from: from ?? new Date(nowMs(options.clock)) });
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
* Occurrences in `(after, until]
|
|
143
|
-
* catch-up possible at all — a scheduler that only knows "now" cannot
|
|
142
|
+
* Occurrences in `(after, until]`, the first `maxCatchUp` of them. Walking forward from the
|
|
143
|
+
* last fire is what makes catch-up possible at all — a scheduler that only knows "now" cannot
|
|
144
|
+
* know what it missed. TRUNCATED, so its last element is the tenth occurrence after the
|
|
145
|
+
* watermark and not the latest one missed; `latestOccurrenceBy` answers that question.
|
|
144
146
|
*/
|
|
145
147
|
const occurrencesSince = (
|
|
146
148
|
handle: TaskHandle,
|
|
@@ -158,6 +160,31 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
158
160
|
return out;
|
|
159
161
|
};
|
|
160
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The latest occurrence at or before `until`, given one is known to lie in `(after, until]`.
|
|
165
|
+
*
|
|
166
|
+
* The resolver only answers "the first occurrence strictly after an instant", and walking it
|
|
167
|
+
* forward from the watermark is bounded by `maxCatchUp` — which is how `skip` came to dispatch
|
|
168
|
+
* the tenth minute after a three-hour outage and then the twentieth, one per tick, for a policy
|
|
169
|
+
* whose whole promise is ONE dispatch (measured: twenty `catchUp=true` dispatches a second apart
|
|
170
|
+
* for a minute cron down 14:23–17:34). So the latest is found by bisection over the instant the
|
|
171
|
+
* resolver is asked from, not by walking: `next(x) <= until` is monotone in `x`, the invariant
|
|
172
|
+
* is `next(lo) <= until < next(hi)`, and at `hi - lo === 1` the one occurrence in `(lo, until]`
|
|
173
|
+
* is `next(lo)`. About 25 resolver calls for a three-hour gap and 35 for a year, whatever the
|
|
174
|
+
* cron's period — never one per missed minute.
|
|
175
|
+
*/
|
|
176
|
+
const latestOccurrenceBy = (handle: TaskHandle, after: number, until: number): number => {
|
|
177
|
+
const nextAfter = (from: number): number => nextRunFor(handle, new Date(from)).getTime();
|
|
178
|
+
let lo = after;
|
|
179
|
+
let hi = until;
|
|
180
|
+
while (hi - lo > 1) {
|
|
181
|
+
const mid = lo + Math.floor((hi - lo) / 2);
|
|
182
|
+
if (nextAfter(mid) <= until) lo = mid;
|
|
183
|
+
else hi = mid;
|
|
184
|
+
}
|
|
185
|
+
return nextAfter(lo);
|
|
186
|
+
};
|
|
187
|
+
|
|
161
188
|
const dispatch = async (
|
|
162
189
|
handle: TaskHandle,
|
|
163
190
|
occurrenceMs: number,
|
|
@@ -242,8 +269,13 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
242
269
|
if (due.length === 0) continue;
|
|
243
270
|
|
|
244
271
|
if (handle.catchUp === 'skip') {
|
|
245
|
-
|
|
246
|
-
|
|
272
|
+
// The real latest occurrence, never `due`'s last element: that one is `maxCatchUp` steps
|
|
273
|
+
// past the watermark, and dispatching it leaves the watermark there — so the next tick
|
|
274
|
+
// found the next ten still due and fired again, until the walk reached `at`. The
|
|
275
|
+
// occurrence key stays honest (this IS the occurrence the payload is for), and the
|
|
276
|
+
// watermark `dispatch` leaves is that occurrence — nothing at or before `at` is due past it.
|
|
277
|
+
const latest = latestOccurrenceBy(handle, last, at);
|
|
278
|
+
dispatched.push(await dispatch(handle, latest, due.length > 1));
|
|
247
279
|
continue;
|
|
248
280
|
}
|
|
249
281
|
if (handle.catchUp === 'run-once') {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The in-memory `StepStore`: what `createMemoryDriver` and every runner test persist steps into.
|
|
2
|
+
// Split from `steps.ts` at the file-size ceiling, along the seam the pg driver already draws —
|
|
3
|
+
// `driver-pg.ts` holds the Postgres store, this file the map-backed one, `steps.ts` the runner
|
|
4
|
+
// both are handed to. Same contract, and `steps.test.ts` is where the contract is pinned.
|
|
5
|
+
|
|
6
|
+
import type { StepRecord, StepStore } from './steps';
|
|
7
|
+
|
|
8
|
+
export function createMemoryStepStore(): StepStore {
|
|
9
|
+
const byRun = new Map<string, Map<string, StepRecord>>();
|
|
10
|
+
const runOf = (runId: string): Map<string, StepRecord> => {
|
|
11
|
+
let run = byRun.get(runId);
|
|
12
|
+
if (run === undefined) {
|
|
13
|
+
run = new Map();
|
|
14
|
+
byRun.set(runId, run);
|
|
15
|
+
}
|
|
16
|
+
return run;
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
get(runId, name) {
|
|
20
|
+
return Promise.resolve(byRun.get(runId)?.get(name));
|
|
21
|
+
},
|
|
22
|
+
put(record) {
|
|
23
|
+
runOf(record.runId).set(record.name, record);
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
},
|
|
26
|
+
list(runId) {
|
|
27
|
+
const records = [...(byRun.get(runId)?.values() ?? [])];
|
|
28
|
+
return Promise.resolve(records.sort((a, b) => a.startedAt - b.startedAt));
|
|
29
|
+
},
|
|
30
|
+
del(runId, name) {
|
|
31
|
+
byRun.get(runId)?.delete(name);
|
|
32
|
+
return Promise.resolve();
|
|
33
|
+
},
|
|
34
|
+
clear(runId) {
|
|
35
|
+
byRun.delete(runId);
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/steps.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
10
|
import { finiteOption, logger, renderThrowable } from '@ultimat3/core';
|
|
11
|
+
import { expectedQueryLoop } from '@ultimat3/db';
|
|
11
12
|
import type { DurationInput } from './clock';
|
|
12
13
|
import { finiteDurationMs, nowMs } from './clock';
|
|
13
14
|
import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
|
|
@@ -113,39 +114,6 @@ export function isStepSuspension(error: unknown): error is StepSuspension {
|
|
|
113
114
|
return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
export function createMemoryStepStore(): StepStore {
|
|
117
|
-
const byRun = new Map<string, Map<string, StepRecord>>();
|
|
118
|
-
const runOf = (runId: string): Map<string, StepRecord> => {
|
|
119
|
-
let run = byRun.get(runId);
|
|
120
|
-
if (run === undefined) {
|
|
121
|
-
run = new Map();
|
|
122
|
-
byRun.set(runId, run);
|
|
123
|
-
}
|
|
124
|
-
return run;
|
|
125
|
-
};
|
|
126
|
-
return {
|
|
127
|
-
get(runId, name) {
|
|
128
|
-
return Promise.resolve(byRun.get(runId)?.get(name));
|
|
129
|
-
},
|
|
130
|
-
put(record) {
|
|
131
|
-
runOf(record.runId).set(record.name, record);
|
|
132
|
-
return Promise.resolve();
|
|
133
|
-
},
|
|
134
|
-
list(runId) {
|
|
135
|
-
const records = [...(byRun.get(runId)?.values() ?? [])];
|
|
136
|
-
return Promise.resolve(records.sort((a, b) => a.startedAt - b.startedAt));
|
|
137
|
-
},
|
|
138
|
-
del(runId, name) {
|
|
139
|
-
byRun.get(runId)?.delete(name);
|
|
140
|
-
return Promise.resolve();
|
|
141
|
-
},
|
|
142
|
-
clear(runId) {
|
|
143
|
-
byRun.delete(runId);
|
|
144
|
-
return Promise.resolve();
|
|
145
|
-
},
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
117
|
export interface StepRunnerOptions {
|
|
150
118
|
readonly runId: string;
|
|
151
119
|
readonly jobName: string;
|
|
@@ -257,6 +225,21 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
257
225
|
|
|
258
226
|
const cancelled = (): boolean => runSignal.aborted;
|
|
259
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The statement itself, declared deliberate to the N+1 detector. One write per step IS the
|
|
230
|
+
* design this file's header states — each step completes at its own instant and its output has
|
|
231
|
+
* to be durable before the next one starts, so five steps are five `SQL_STEP_PUT`s that no
|
|
232
|
+
* batch could replace. Without the declaration `x dev` warned `X_N_PLUS_ONE_WRITE` on every
|
|
233
|
+
* job of five or more steps, a verdict against the framework's own persistence that an app
|
|
234
|
+
* could neither fix nor silence. The scope ends with the write: the hydrating `list` and the
|
|
235
|
+
* job's own statements are judged as before.
|
|
236
|
+
*/
|
|
237
|
+
const persist = (record: StepRecord): Promise<void> =>
|
|
238
|
+
expectedQueryLoop(
|
|
239
|
+
'a durable step is written the instant it completes, one statement per step by design',
|
|
240
|
+
() => store.put(record),
|
|
241
|
+
);
|
|
242
|
+
|
|
260
243
|
/**
|
|
261
244
|
* EVERY write this runner makes, and the one place the cancellation is enforced. A step result
|
|
262
245
|
* from a cancelled attempt is a write onto the attempt that replaced it: the deadline nacked
|
|
@@ -265,7 +248,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
265
248
|
*/
|
|
266
249
|
const put = async (record: StepRecord): Promise<void> => {
|
|
267
250
|
if (cancelled()) throw new JobAbortedError({ job: jobName, step: record.name });
|
|
268
|
-
await
|
|
251
|
+
await persist(record);
|
|
269
252
|
remember(record);
|
|
270
253
|
};
|
|
271
254
|
|
|
@@ -329,7 +312,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
329
312
|
attempts,
|
|
330
313
|
error: renderThrowable(error),
|
|
331
314
|
};
|
|
332
|
-
await
|
|
315
|
+
await persist(failure);
|
|
333
316
|
remember(failure);
|
|
334
317
|
}
|
|
335
318
|
throw error;
|
package/src/webhook.ts
CHANGED
|
@@ -19,6 +19,10 @@ import type { Clock, Ctx } from '@ultimat3/core';
|
|
|
19
19
|
import {
|
|
20
20
|
finiteOption,
|
|
21
21
|
isCanonicalWebhookField,
|
|
22
|
+
// Core's ONE table, never a fifth copy of it. The copy that lived here omitted 409, so a
|
|
23
|
+
// receiver saying "a concurrent writer won this round" dead-lettered on attempt 1 as a refusal
|
|
24
|
+
// no retry could change — the exact divergence `retryable-status.ts` was extracted to end.
|
|
25
|
+
isRetryableStatus,
|
|
22
26
|
renderThrowable,
|
|
23
27
|
systemClock,
|
|
24
28
|
WEBHOOK_FIELD_MAX,
|
|
@@ -164,10 +168,6 @@ const retryAfterSeconds = (response: Response): number | undefined => {
|
|
|
164
168
|
return Number(header.trim());
|
|
165
169
|
};
|
|
166
170
|
|
|
167
|
-
/** A status the same request, unchanged, can still land on. Everything else is somebody's edit. */
|
|
168
|
-
const isRetryableStatus = (status: number): boolean =>
|
|
169
|
-
status >= 500 || status === 408 || status === 425 || status === 429;
|
|
170
|
-
|
|
171
171
|
export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliveryInput> {
|
|
172
172
|
const clock = definition.clock ?? systemClock;
|
|
173
173
|
const disableAfter = finiteOption(
|