@ultimat3/jobs 19.2.0 → 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 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`. Never `http`, `render`, `ui`.
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 relay, the ambient `JobsFacade` slot |
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.2.0",
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.2.0",
36
- "@ultimat3/entity": "19.2.0",
37
- "@ultimat3/schema": "19.2.0",
38
- "@ultimat3/time": "19.2.0"
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
  }
@@ -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(jobId, {
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
- enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
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 Promise.resolve({ id: existing.id, runId: existing.runId, deduped: true });
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 Promise.resolve({ id: record.id, runId: record.runId, deduped: false });
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
@@ -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, plus the runnable command for whatever is
20
- // already queued. NOT `jobs: { driver }` in app.config.ts, which this line said until 2026-08-20:
21
- // `JobsConfig.driver` has no reader anywhere (see `driver.ts`'s header), so that edit repairs
22
- // nothing and the reader is sent back to the same throw. #223 removes the field.
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, then move what is already queued: x jobs drain --to memory --json';
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 can
58
- * depend on no database package at all.
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 }`
@@ -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, plus the runnable command for whatever is
23
- // already queued. NOT `jobs: { driver }` in app.config.ts, which this line said until 2026-08-20:
24
- // `JobsConfig.driver` has no reader anywhere (see `driver.ts`'s header), so that edit repairs
25
- // nothing and the reader is sent back to the same throw. #223 removes the field.
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, then move what is already queued: x jobs drain --to memory --json';
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]`. 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(