@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
|
@@ -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(
|
package/src/worker-run.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface RunClaimedOptions {
|
|
|
27
27
|
readonly heartbeatIntervalMs: number;
|
|
28
28
|
readonly clock?: Clock;
|
|
29
29
|
readonly events?: EventLookup;
|
|
30
|
+
/**
|
|
31
|
+
* The worker's drain, composed into every run it starts: aborted with a `JobDrainedError` when
|
|
32
|
+
* the process is going away, so the body hears it on `ctx.signal` — the one seam it already
|
|
33
|
+
* reads — before core's in-flight wait starts spending the budget on it.
|
|
34
|
+
*/
|
|
35
|
+
readonly drain?: AbortSignal;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/** A name this deploy does not know, parked rather than failed — almost always a deploy skew. */
|
|
@@ -95,8 +101,10 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
95
101
|
// controller this worker owns rather than `AbortSignal.any`, for two reasons: it is handed BACK
|
|
96
102
|
// when the run settles (an app whose `context()` carries a process-lifetime signal was
|
|
97
103
|
// accumulating one composite per job), and the worker can abort it itself — which is the only
|
|
98
|
-
// way a fleet slot taken by somebody else reaches the body running under it.
|
|
99
|
-
|
|
104
|
+
// way a fleet slot taken by somebody else reaches the body running under it. The worker's
|
|
105
|
+
// drain is the third source: SIGTERM reaches the body through the same signal, carrying the
|
|
106
|
+
// `X_DRAINING` reason `executeJob` reads to hand the attempt back uncounted.
|
|
107
|
+
runSignal = createRunSignal([base.signal, heartbeat.signal, options.drain]);
|
|
100
108
|
const signal = runSignal;
|
|
101
109
|
const ctx: Ctx = { ...base, signal: signal.signal };
|
|
102
110
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The `worker` role's public contract: what `createWorker` takes, what it hands back, and what
|
|
2
|
+
// `stats()` reports. Apart from `worker.ts` because that file's job is the claim loop and the
|
|
3
|
+
// drain, and a contract three files import should not sit under 500 lines of loop.
|
|
4
|
+
|
|
5
|
+
import type { Clock, Ctx } from '@ultimat3/core';
|
|
6
|
+
import type { JobDriver, QueueStats } from './driver';
|
|
7
|
+
import type { JobExecution } from './execute';
|
|
8
|
+
import type { Limiter } from './limits';
|
|
9
|
+
import type { EventLookup } from './steps';
|
|
10
|
+
|
|
11
|
+
export interface WorkerOptions {
|
|
12
|
+
readonly driver: JobDriver;
|
|
13
|
+
/** Queues this process serves. Default `['default']`. */
|
|
14
|
+
readonly queues?: readonly string[];
|
|
15
|
+
/** Slots per queue. A number applies to every queue. */
|
|
16
|
+
readonly concurrency?: number | Readonly<Record<string, number>>;
|
|
17
|
+
readonly limiter?: Limiter;
|
|
18
|
+
readonly clock?: Clock;
|
|
19
|
+
readonly events?: EventLookup;
|
|
20
|
+
/** Supplies the ambient Ctx for a job run; the app wires ALS + tenant here. */
|
|
21
|
+
readonly context: () => Ctx;
|
|
22
|
+
readonly visibilityTimeoutMs?: number;
|
|
23
|
+
readonly pollIntervalMs?: number;
|
|
24
|
+
readonly heartbeatIntervalMs?: number;
|
|
25
|
+
readonly workerId?: string;
|
|
26
|
+
/** Default true. Registers a SIGTERM drain via `onShutdown`. */
|
|
27
|
+
readonly drainOnShutdown?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WorkerStats {
|
|
31
|
+
readonly workerId: string;
|
|
32
|
+
readonly queues: readonly string[];
|
|
33
|
+
readonly state: 'idle' | 'running' | 'draining' | 'stopped';
|
|
34
|
+
readonly inFlight: number;
|
|
35
|
+
readonly processed: number;
|
|
36
|
+
readonly failed: number;
|
|
37
|
+
readonly suspended: number;
|
|
38
|
+
readonly deadLettered: number;
|
|
39
|
+
/** Attempts this worker's drain cut short and handed back uncounted — see `JobDrainedError`. */
|
|
40
|
+
readonly interrupted: number;
|
|
41
|
+
readonly queueDepth: readonly QueueStats[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Worker {
|
|
45
|
+
start(): void;
|
|
46
|
+
/** One claim+run round. Returns jobs processed. Tests drive this instead of the timer. */
|
|
47
|
+
tick(): Promise<readonly JobExecution[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Stop claiming, wait for every job this worker holds, close the driver. Unbounded, and it
|
|
50
|
+
* aborts nothing: a caller that asked wants its work finished. SIGTERM takes the other path —
|
|
51
|
+
* the shutdown hooks `start()` registers abort every held run's `ctx.signal` and wait under
|
|
52
|
+
* the lifecycle's deadline.
|
|
53
|
+
*/
|
|
54
|
+
stop(reason?: string): Promise<void>;
|
|
55
|
+
stats(): Promise<WorkerStats>;
|
|
56
|
+
}
|