@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.
@@ -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]`. Walking forward from the last fire is what makes
143
- * catch-up possible at all — a scheduler that only knows "now" cannot know what it missed.
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
- const latest = due[due.length - 1];
246
- if (latest !== undefined) dispatched.push(await dispatch(handle, latest, due.length > 1));
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 store.put(record);
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 store.put(failure);
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
- runSignal = createRunSignal([base.signal, heartbeat.signal]);
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
+ }