@ultimat3/jobs 1.2.0 → 3.0.0

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.
Files changed (47) hide show
  1. package/CLAUDE.md +660 -0
  2. package/README.md +432 -17
  3. package/package.json +7 -5
  4. package/src/backfill-gate.ts +97 -0
  5. package/src/backfill-inspect.ts +73 -0
  6. package/src/backfill-ledger.ts +183 -0
  7. package/src/backfill-pass.ts +276 -0
  8. package/src/backfill-pending.ts +131 -0
  9. package/src/backfill-rate.ts +109 -0
  10. package/src/backfill-registry.ts +108 -0
  11. package/src/backfill-scope.ts +70 -0
  12. package/src/backfill.ts +213 -0
  13. package/src/driver-memory.ts +61 -9
  14. package/src/driver-nats.ts +2 -1
  15. package/src/driver-pg-ddl.ts +191 -0
  16. package/src/driver-pg-rows.ts +123 -0
  17. package/src/driver-pg-sql.ts +312 -55
  18. package/src/driver-pg.ts +138 -92
  19. package/src/driver-redis.ts +2 -1
  20. package/src/driver.ts +91 -7
  21. package/src/errors.ts +314 -5
  22. package/src/events-pg.ts +121 -0
  23. package/src/events.ts +7 -1
  24. package/src/execute.ts +308 -0
  25. package/src/heartbeat.ts +148 -0
  26. package/src/index.ts +128 -27
  27. package/src/inspect.ts +43 -2
  28. package/src/job.ts +127 -3
  29. package/src/leases.ts +90 -0
  30. package/src/limits.ts +0 -0
  31. package/src/metrics.ts +35 -0
  32. package/src/outbox-lease.ts +29 -0
  33. package/src/outbox-pg.ts +188 -0
  34. package/src/outbox.ts +204 -59
  35. package/src/register.ts +1 -1
  36. package/src/renewal-timer.ts +35 -0
  37. package/src/retry-classification.ts +112 -0
  38. package/src/retry.ts +6 -1
  39. package/src/run-signal.ts +50 -0
  40. package/src/scheduler-pg.ts +103 -0
  41. package/src/scheduler.ts +159 -245
  42. package/src/steps.ts +155 -31
  43. package/src/task.ts +239 -0
  44. package/src/tenant.ts +61 -0
  45. package/src/worker-fleet-slots.ts +129 -0
  46. package/src/worker-run.ts +132 -0
  47. package/src/worker.ts +207 -190
@@ -0,0 +1,29 @@
1
+ // The claim lease's one definition and its one normalisation. Both outbox stores read it, because
2
+ // a lease the memory store defaults and the pg store validates is two answers to "how long is a
3
+ // claim mine for" — and the shorter of the two is a row published twice.
4
+
5
+ import { assert } from '@ultimat3/core';
6
+
7
+ /**
8
+ * How long a claimed row stays its claimant's. Long enough that no healthy pass loses a batch it
9
+ * is still publishing, short enough that a relay killed mid-batch does not strand one for minutes.
10
+ */
11
+ export const DEFAULT_OUTBOX_CLAIM_LEASE_MS = 30_000;
12
+
13
+ /**
14
+ * Refused where it is written, the way `concurrency: 0` and `stepTimeout: 0` are. A lease of `0`
15
+ * expires before `claim()` resolves, so every relay reclaims every row on every tick and the lease
16
+ * buys nothing; a fractional one is compared against `now()` in Postgres and against whole ms
17
+ * here; `Infinity` never expires, so the rows of a relay that died are stranded forever — the one
18
+ * failure the lease exists to bound. `X_INVARIANT` because this is a caller-argument check with no
19
+ * dedicated code, the generic `@ultimat3/db` already borrows for the same shape.
20
+ */
21
+ export function resolveClaimLeaseMs(value: number | undefined): number {
22
+ if (value === undefined) return DEFAULT_OUTBOX_CLAIM_LEASE_MS;
23
+ assert(
24
+ Number.isInteger(value) && value > 0,
25
+ `outbox claimLeaseMs is ${String(value)}, which is not a positive whole number of milliseconds`,
26
+ 'pass a positive whole claimLeaseMs: 30_000 — createPgOutboxStore({ executor, txExecutor, claimLeaseMs: 30_000 }) — or omit the field for the 30s default',
27
+ );
28
+ return value;
29
+ }
@@ -0,0 +1,188 @@
1
+ // The `x_outbox` implementation of `OutboxStore` — the half of the transactional outbox that
2
+ // makes it transactional. `stage()` runs on the CALLER'S OWN connection, so the queue row is in
3
+ // the same Postgres transaction as the business rows and commits or vanishes with them. The
4
+ // memory store hangs rows off the `Tx` object and is correct only inside one process; this is
5
+ // what a deployment installs.
6
+ //
7
+ // `txExecutor` is injected rather than resolved here for the reason `createPgDriver` takes a
8
+ // `PgExecutor`: this package holds no `@ultimat3/db` dependency, and "which connection is this
9
+ // `Tx` on" is a question only boot can answer. Boot has `currentTx()` — a `DbTx` IS a client on
10
+ // the transaction's connection — so the wiring is one line there and no tier crossing here.
11
+
12
+ import type { Clock } from '@ultimat3/core';
13
+ import { uuid } from '@ultimat3/core';
14
+ import type { Tx } from '@ultimat3/entity';
15
+ import { nowMs } from './clock';
16
+ import type { PgExecutor } from './driver-pg';
17
+ import {
18
+ SQL_OUTBOX_CLAIM,
19
+ SQL_OUTBOX_MARK_PUBLISHED,
20
+ SQL_OUTBOX_PENDING_COUNT,
21
+ SQL_OUTBOX_RELEASE,
22
+ SQL_OUTBOX_STAGE,
23
+ } from './driver-pg-sql';
24
+ import type { OutboxRecord, OutboxStore } from './outbox';
25
+ import { resolveClaimLeaseMs } from './outbox-lease';
26
+
27
+ interface OutboxRow {
28
+ readonly id: string;
29
+ readonly job: string;
30
+ readonly queue: string;
31
+ readonly input: unknown;
32
+ readonly idempotency_key: string;
33
+ readonly max_attempts: number;
34
+ readonly run_at: number | string;
35
+ readonly staged_at: number | string;
36
+ readonly tenant_id: string | null;
37
+ readonly traceparent: string | null;
38
+ readonly enqueued_by: string | null;
39
+ readonly claimed_by?: string | null;
40
+ }
41
+
42
+ export interface PgOutboxOptions {
43
+ /**
44
+ * The pooled executor the RELAY uses: `claim`, `markPublished` and `pendingCount` all run after
45
+ * the caller's transaction is gone, so they must not be bound to it.
46
+ */
47
+ readonly executor: PgExecutor;
48
+ /**
49
+ * The executor bound to `tx`'s own connection. Boot supplies it; without it `stage()` would
50
+ * write on a second connection and the outbox would guarantee nothing at all.
51
+ */
52
+ readonly txExecutor: (tx: Tx) => PgExecutor;
53
+ readonly clock?: Clock;
54
+ /**
55
+ * How long a claimed row stays this relay's before any relay may take it again. It bounds one
56
+ * thing only: how long the rows of a relay that DIED mid-batch sit unpublished. A pass that is
57
+ * merely slow keeps its rows because it published them; a pass that failed hands them back
58
+ * through `release`.
59
+ */
60
+ readonly claimLeaseMs?: number;
61
+ /**
62
+ * Written to `claimed_by`, and read back as the FENCE on `release` and `markPublished` — so it
63
+ * must be UNIQUE PER PROCESS. Two replicas passing one literal are one claimant to Postgres, and
64
+ * each can then release or retire the other's live batch. Omit it: the default is
65
+ * `relay-<uuid>`, minted once per store, which is unique by construction. Diagnostics second —
66
+ * it is what an operator reads to see which relay is sitting on a batch, and a value that
67
+ * changed every tick would answer nobody.
68
+ */
69
+ readonly relayId?: string;
70
+ }
71
+
72
+ function toRecord(row: OutboxRow): OutboxRecord {
73
+ return {
74
+ id: row.id,
75
+ job: row.job,
76
+ queue: row.queue,
77
+ input: row.input,
78
+ idempotencyKey: row.idempotency_key,
79
+ maxAttempts: Number(row.max_attempts),
80
+ runAt: Number(row.run_at),
81
+ stagedAt: Number(row.staged_at),
82
+ ...(row.tenant_id === null ? {} : { tenantId: row.tenant_id }),
83
+ ...(row.traceparent === null ? {} : { traceparent: row.traceparent }),
84
+ ...(row.enqueued_by === null ? {} : { enqueuedBy: row.enqueued_by }),
85
+ ...(typeof row.claimed_by === 'string' ? { claimedBy: row.claimed_by } : {}),
86
+ };
87
+ }
88
+
89
+ export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
90
+ // What each open transaction has staged, for `commit()`'s return value only. Never the source
91
+ // of truth — that is the row, and the row's fate is the transaction's. A WeakMap so a `Tx` that
92
+ // is neither committed nor rolled back (a process killed mid-request) leaves nothing behind.
93
+ const staged = new WeakMap<object, OutboxRecord[]>();
94
+ const key = (tx: Tx): object => tx as unknown as object;
95
+ // One id per store, minted here rather than per claim: `claimed_by` is read by an operator
96
+ // asking which relay is sitting on a batch, and a value that changed every tick answers nobody.
97
+ // Per-store is also the granularity the fence needs — two relays are two processes, two stores.
98
+ const relayId = options.relayId ?? `relay-${uuid()}`;
99
+ // Resolved once, at construction, so a lease this store could never honour fails where it was
100
+ // written instead of inside a relay tick whose only trace is a log line nobody reads.
101
+ const claimLeaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
102
+
103
+ return {
104
+ async stage(tx, record) {
105
+ await options
106
+ .txExecutor(tx)
107
+ .query(SQL_OUTBOX_STAGE, [
108
+ record.id,
109
+ record.job,
110
+ record.queue,
111
+ JSON.stringify(record.input ?? null),
112
+ record.idempotencyKey,
113
+ record.maxAttempts,
114
+ record.runAt,
115
+ record.stagedAt,
116
+ record.tenantId ?? null,
117
+ record.traceparent ?? null,
118
+ record.enqueuedBy ?? null,
119
+ ]);
120
+ const bucket = staged.get(key(tx)) ?? [];
121
+ bucket.push(record);
122
+ staged.set(key(tx), bucket);
123
+ },
124
+
125
+ /**
126
+ * Nothing to do but report. The rows became visible when Postgres committed them — there is
127
+ * no second write here, and that absence IS the guarantee: a commit hook that had to run
128
+ * would be one more thing between the business rows and the job row.
129
+ */
130
+ commit(tx) {
131
+ const bucket = staged.get(key(tx)) ?? [];
132
+ staged.delete(key(tx));
133
+ return Promise.resolve(bucket);
134
+ },
135
+
136
+ /** Also nothing: the ROLLBACK already took the rows. Only the bookkeeping is ours to drop. */
137
+ rollback(tx) {
138
+ staged.delete(key(tx));
139
+ return Promise.resolve();
140
+ },
141
+
142
+ /**
143
+ * A CLAIM, not a read. `for update skip locked` in a bare select held its locks only for that
144
+ * statement — which under autocommit is over before this method resolves — so two relays
145
+ * polling 200ms apart got the identical batch and both published it. The idempotency key
146
+ * collapses that only while the first job is still live, so the repeat that lands after it
147
+ * finished runs the handler a second time. `SQL_OUTBOX_CLAIM` stamps `claimed_at` in the same
148
+ * statement that locks the row; `skip locked` still keeps two relays from serialising.
149
+ */
150
+ async claim(limit) {
151
+ const rows = await options.executor.query<OutboxRow>(SQL_OUTBOX_CLAIM, [
152
+ limit,
153
+ claimLeaseMs,
154
+ relayId,
155
+ ]);
156
+ return rows.map(toRecord);
157
+ },
158
+
159
+ /**
160
+ * Fenced on the CLAIMANT, not only on the ids. A relay that stalled past its lease wakes into
161
+ * a world where its batch belongs to another relay, and an unfenced release frees rows that
162
+ * relay is mid-publish on — a third relay claims them and publishes them again. `relayId` is
163
+ * the fallback because it is what this store stamped: a caller with no token is this store's
164
+ * own relay, and one holding somebody else's token could not have got it from here.
165
+ */
166
+ async release(ids, claimant) {
167
+ if (ids.length === 0) return;
168
+ await options.executor.query(SQL_OUTBOX_RELEASE, [ids, claimant ?? relayId]);
169
+ },
170
+
171
+ /** Same fence, and worse to miss: marking a row published is losing the job behind it. */
172
+ async markPublished(id, at, claimant) {
173
+ await options.executor.query(SQL_OUTBOX_MARK_PUBLISHED, [
174
+ id,
175
+ at || nowMs(options.clock),
176
+ claimant ?? relayId,
177
+ ]);
178
+ },
179
+
180
+ async pendingCount() {
181
+ const rows = await options.executor.query<{ pending: number | string }>(
182
+ SQL_OUTBOX_PENDING_COUNT,
183
+ [],
184
+ );
185
+ return Number(rows[0]?.pending ?? 0);
186
+ },
187
+ };
188
+ }
package/src/outbox.ts CHANGED
@@ -1,6 +1,5 @@
1
- // The transactional outbox — ON BY DEFAULT, because the alternative is a bug you cannot see
2
- // in review. `ctx.jobs.enqueue()` inside a request writes the job row in the SAME `tx` as the
3
- // business rows and a relay publishes it after commit. Without it, every enqueue is a
1
+ // The transactional outbox. `ctx.jobs.enqueue()` inside a request writes the job row in the SAME
2
+ // `tx` as the business rows and a relay publishes it after commit. Without it, every enqueue is a
4
3
  // distributed-transaction coin flip:
5
4
  //
6
5
  // enqueue then rollback -> the job runs against rows that never existed
@@ -9,15 +8,30 @@
9
8
  // Both are load-dependent, both pass every test, and both are the top source of "the email
10
9
  // went out but the order isn't in the database" tickets. Joining the transaction removes the
11
10
  // window entirely; the relay's at-least-once delivery is deduped by the job's idempotencyKey.
11
+ //
12
+ // **It is NOT on by default, and this header used to claim it was** (`As of 2026-08`). Three
13
+ // things have to be true in a process for an enqueue to be transactional, and the fallback at
14
+ // `jobsFacade()` is what happens when they are not:
15
+ //
16
+ // 1. `x_outbox` exists — it ships in `SQL_JOBS_TABLE` now, so applying the queue DDL is enough.
17
+ // 2. `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` ran at boot, with a store
18
+ // from `createPgOutboxStore` and a REAL `currentTx` accessor.
19
+ // 3. `createOutboxRelay({ store, driver }).start()` is running somewhere.
20
+ //
21
+ // With none of them, `jobsFacade()` answers the fallback below, whose `currentTx` is
22
+ // `() => undefined`: every enqueue publishes straight to the driver, outside the caller's
23
+ // transaction, and both failure modes above are live. That fallback is deliberate — a script, a
24
+ // test and `x dev` must enqueue with nothing wired — but it is a fallback, not the guarantee.
12
25
 
13
26
  import type { Clock } from '@ultimat3/core';
14
- import { logger, uuid } from '@ultimat3/core';
27
+ import { currentSpanContext, logger, traceparent, uuid } from '@ultimat3/core';
15
28
  import type { Tx } from '@ultimat3/entity';
16
29
  import { nowMs } from './clock';
17
30
  import type { EnqueueResult, JobDriver } from './driver';
18
31
  import { DEFAULT_QUEUE, jobDriver } from './driver';
19
32
  import { DriverUnavailableError, OutboxNoTxError } from './errors';
20
33
  import type { JobHandle } from './job';
34
+ import { resolveClaimLeaseMs } from './outbox-lease';
21
35
 
22
36
  export interface OutboxRecord {
23
37
  readonly id: string;
@@ -29,7 +43,15 @@ export interface OutboxRecord {
29
43
  readonly runAt: number;
30
44
  readonly stagedAt: number;
31
45
  readonly tenantId?: string;
46
+ /** The enqueuing request's trace, carried across the relay so the job's span still has a parent. */
47
+ readonly traceparent?: string;
48
+ readonly enqueuedBy?: string;
32
49
  readonly publishedAt?: number;
50
+ /**
51
+ * Stamped by `claim()`, absent on a staged row. Hand it back to `release`/`markPublished`: it is
52
+ * the FENCE, so a claimant whose lease lapsed cannot touch the rows a newer one is publishing.
53
+ */
54
+ readonly claimedBy?: string;
33
55
  }
34
56
 
35
57
  export interface OutboxStore {
@@ -39,22 +61,81 @@ export interface OutboxStore {
39
61
  commit(tx: Tx): Promise<readonly OutboxRecord[]>;
40
62
  /** Called by the tx runner after ROLLBACK. Staged rows vanish with the transaction. */
41
63
  rollback(tx: Tx): Promise<void>;
42
- /** Unpublished, committed rows — the relay's work queue. */
64
+ /**
65
+ * CLAIM unpublished, committed rows — the relay's work queue, and a lease rather than a read.
66
+ * A store that hands the same rows to two relays hands the same job to two workers, and the
67
+ * idempotency key only collapses that while the first job is still live.
68
+ */
43
69
  claim(limit: number): Promise<readonly OutboxRecord[]>;
44
- markPublished(id: string, at: number): Promise<void>;
70
+ /**
71
+ * Hand a claim back before its lease runs out, for the batch a failed publish stopped. OPTIONAL
72
+ * so a store written before the claim became a lease still compiles: without it those rows wait
73
+ * out the whole lease, which is slower, never wrong.
74
+ *
75
+ * `claimant` is the `claimedBy` the claim stamped. Passing it is what makes a lapsed relay's
76
+ * late release a no-op instead of an unclaim of somebody else's live batch.
77
+ */
78
+ release?(ids: readonly string[], claimant?: string): Promise<void>;
79
+ /** `claimant` fences the same way, and here it is worse to miss: this retires the row. */
80
+ markPublished(id: string, at: number, claimant?: string): Promise<void>;
45
81
  pendingCount(): Promise<number>;
46
82
  }
47
83
 
84
+ /**
85
+ * The claim's sort key, and it is TOTAL: `id` after `stagedAt`, exactly what `SQL_OUTBOX_CLAIM`
86
+ * orders by. Every row staged in one transaction shares a `stagedAt`, so the key ties for the
87
+ * batch that most depends on order — and a tie leaves both which rows a limit takes and the order
88
+ * they publish in to whatever the store iterated first. Code units, never `localeCompare`, for
89
+ * the reason `registeredJobs()` sorts that way.
90
+ */
91
+ function byClaimOrder(a: OutboxRecord, b: OutboxRecord): number {
92
+ if (a.stagedAt !== b.stagedAt) return a.stagedAt - b.stagedAt;
93
+ if (a.id === b.id) return 0;
94
+ return a.id < b.id ? -1 : 1;
95
+ }
96
+
97
+ export interface MemoryOutboxOptions {
98
+ readonly clock?: Clock;
99
+ readonly claimLeaseMs?: number;
100
+ }
101
+
102
+ export interface MemoryOutboxStore extends OutboxStore {
103
+ /**
104
+ * Committed rows this process is still holding. The relay's backlog and nothing else — a
105
+ * published row is dropped, so this is a bound, not a total. `x dev` and the tests are the
106
+ * only readers; a pg deployment reads `x_outbox` instead.
107
+ */
108
+ retained(): number;
109
+ }
110
+
48
111
  /**
49
112
  * Default store. Staged rows hang off the `Tx` object itself in a WeakMap, so the "same
50
113
  * transaction" guarantee needs no cooperation from the DB layer and rollback is a delete.
51
114
  * The pg store swaps this for a real `x_outbox` table written by the same connection.
52
115
  */
53
- export function createMemoryOutboxStore(): OutboxStore {
116
+ export function createMemoryOutboxStore(options: MemoryOutboxOptions = {}): MemoryOutboxStore {
54
117
  const staged = new WeakMap<object, OutboxRecord[]>();
55
118
  const committed = new Map<string, OutboxRecord>();
119
+ /** Each claimed row's lease: when it was taken and by whom. Absent is `claimed_at is null`. */
120
+ const claims = new Map<string, { at: number; by: string }>();
121
+ const leaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
122
+ // A token per CLAIM, where the pg store stamps one per RELAY. Two relays there are two stores
123
+ // with two ids; here they are two `claim()` calls on one store, so the claim is the only
124
+ // granularity at which this store can answer "is this mutation from the current holder".
125
+ let claimSeq = 0;
56
126
 
57
127
  const key = (tx: Tx): object => tx as unknown as object;
128
+ const free = (id: string, at: number): boolean => {
129
+ const claim = claims.get(id);
130
+ return claim === undefined || at - claim.at >= leaseMs;
131
+ };
132
+ /**
133
+ * A mutation from a claimant that no longer holds the row is a NO-OP. `undefined` is the caller
134
+ * that holds no token at all — a store-level caller, or one written before the fence — and is
135
+ * left unfenced rather than silently dropped, the way `release` itself is optional.
136
+ */
137
+ const owns = (id: string, claimant: string | undefined): boolean =>
138
+ claimant === undefined || claims.get(id)?.by === claimant;
58
139
 
59
140
  return {
60
141
  stage(tx, record) {
@@ -73,16 +154,36 @@ export function createMemoryOutboxStore(): OutboxStore {
73
154
  staged.delete(key(tx));
74
155
  return Promise.resolve();
75
156
  },
157
+ /**
158
+ * The same question `SQL_OUTBOX_CLAIM` answers, and it has to stay the same one: a row this
159
+ * store hands back is CLAIMED for `leaseMs`, so a second relay polling the same store gets
160
+ * nothing, and a claim whose holder died is reclaimable once the window passes.
161
+ */
76
162
  claim(limit) {
163
+ const at = nowMs(options.clock);
164
+ claimSeq += 1;
165
+ const by = `claim-${claimSeq}`;
77
166
  const ready = [...committed.values()]
78
- .filter((record) => record.publishedAt === undefined)
79
- .sort((a, b) => a.stagedAt - b.stagedAt)
167
+ .filter((record) => record.publishedAt === undefined && free(record.id, at))
168
+ .sort(byClaimOrder)
80
169
  .slice(0, limit);
81
- return Promise.resolve(ready);
170
+ for (const record of ready) claims.set(record.id, { at, by });
171
+ return Promise.resolve(ready.map((record) => ({ ...record, claimedBy: by })));
172
+ },
173
+ release(ids, claimant) {
174
+ for (const id of ids) {
175
+ if (owns(id, claimant)) claims.delete(id);
176
+ }
177
+ return Promise.resolve();
82
178
  },
83
- markPublished(id, at) {
84
- const record = committed.get(id);
85
- if (record !== undefined) committed.set(id, { ...record, publishedAt: at });
179
+ markPublished(id, _at, claimant) {
180
+ if (!owns(id, claimant)) return Promise.resolve();
181
+ // Deleted, not stamped. A published row is out of the relay's reach either way, and the
182
+ // pg store's `published_at` column is a retained audit trail this map is not: rewriting
183
+ // it in place held every payload ever enqueued — arbitrary job input — for the life of
184
+ // the process, and made `claim()` and `pendingCount()` walk all of them every 200ms.
185
+ committed.delete(id);
186
+ claims.delete(id);
86
187
  return Promise.resolve();
87
188
  },
88
189
  pendingCount() {
@@ -92,6 +193,7 @@ export function createMemoryOutboxStore(): OutboxStore {
92
193
  }
93
194
  return Promise.resolve(count);
94
195
  },
196
+ retained: () => committed.size,
95
197
  };
96
198
  }
97
199
 
@@ -102,6 +204,29 @@ export interface EnqueueOptions {
102
204
  readonly queue?: string;
103
205
  /** Escape hatch for enqueues that must fire regardless of the caller's transaction. */
104
206
  readonly outbox?: boolean;
207
+ /**
208
+ * Who asked. AUDIT ONLY — the job body still runs with system authority. `handle.as(actor, ...)`
209
+ * fills it from the actor; set it directly only where there is no actor object to hand over.
210
+ */
211
+ readonly enqueuedBy?: string;
212
+ /** Override the ambient trace. Almost never: the facade stamps the current span for you. */
213
+ readonly traceparent?: string;
214
+ }
215
+
216
+ /**
217
+ * The W3C `traceparent` of the span this enqueue is happening inside, or `undefined` outside a
218
+ * trace. This is the ONE place the link is minted: `docs/idea/04-jobs.md` promises a job trace
219
+ * linked to the enqueuing request, and before this there was no field to carry the link, so a
220
+ * checkout's `chargeCard` opened a fresh root two seconds later with nothing pointing back.
221
+ *
222
+ * A context recovered from a `Ctx` has an empty `spanId` (`currentSpanContext`), which renders as
223
+ * an all-zero parent that every collector rejects — so that case carries no header rather than a
224
+ * malformed one, and the job opens a root as it did before.
225
+ */
226
+ function ambientTraceparent(): string | undefined {
227
+ const context = currentSpanContext();
228
+ if (context === undefined || context.spanId === '') return undefined;
229
+ return traceparent(context);
105
230
  }
106
231
 
107
232
  export interface OutboxDeps {
@@ -121,6 +246,7 @@ export function enqueueInTx<I>(
121
246
  options: EnqueueOptions = {},
122
247
  ): Promise<OutboxRecord> {
123
248
  const at = nowMs(deps.clock);
249
+ const trace = options.traceparent ?? ambientTraceparent();
124
250
  const record: OutboxRecord = {
125
251
  id: uuid(),
126
252
  job: handle.name,
@@ -131,6 +257,10 @@ export function enqueueInTx<I>(
131
257
  runAt: options.runAt ?? at,
132
258
  stagedAt: at,
133
259
  ...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
260
+ // Stamped at STAGE time and not at publish time: the relay runs after commit, in its own
261
+ // timer, with no request span in scope — a trace read there would be nobody's.
262
+ ...(trace === undefined ? {} : { traceparent: trace }),
263
+ ...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
134
264
  };
135
265
  return deps.store.stage(tx, record).then(() => record);
136
266
  }
@@ -158,6 +288,7 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
158
288
  if (deps.mode === 'required' && options.outbox !== false) {
159
289
  throw new OutboxNoTxError({ job: handle.name });
160
290
  }
291
+ const trace = options.traceparent ?? ambientTraceparent();
161
292
  return deps.driver.enqueue({
162
293
  name: handle.name,
163
294
  queue: options.queue ?? handle.queue,
@@ -166,6 +297,8 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
166
297
  maxAttempts: handle.retry.attempts,
167
298
  runAt: options.runAt ?? nowMs(deps.clock),
168
299
  ...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
300
+ ...(trace === undefined ? {} : { traceparent: trace }),
301
+ ...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
169
302
  });
170
303
  }
171
304
 
@@ -233,7 +366,13 @@ export interface OutboxRelay {
233
366
  /** One pass. Returns how many rows were published. Call it directly in tests. */
234
367
  tick(): Promise<number>;
235
368
  start(): void;
236
- stop(): void;
369
+ /**
370
+ * Stop polling and WAIT OUT the pass in flight, the way `worker.stop()` waits out its rounds and
371
+ * `scheduler.stop()` its dispatch. A pass is a publish followed by a `markPublished`, and a
372
+ * caller that returned between the two closed the database under the row it was about to mark:
373
+ * re-published next boot at best, a rejection against a closed pool at worst.
374
+ */
375
+ stop(): Promise<void>;
237
376
  pending(): Promise<number>;
238
377
  }
239
378
 
@@ -246,6 +385,8 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
246
385
  const intervalMs = options.intervalMs ?? 200;
247
386
  let timer: ReturnType<typeof setInterval> | undefined;
248
387
  let running = false;
388
+ /** The pass in flight, so `stop()` joins it instead of returning underneath it. */
389
+ let pass: Promise<void> | undefined;
249
390
 
250
391
  const tick = async (): Promise<number> => {
251
392
  const batch = await options.store.claim(batchSize);
@@ -260,16 +401,36 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
260
401
  maxAttempts: record.maxAttempts,
261
402
  runAt: record.runAt,
262
403
  ...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
404
+ ...(record.traceparent === undefined ? {} : { traceparent: record.traceparent }),
405
+ ...(record.enqueuedBy === undefined ? {} : { enqueuedBy: record.enqueuedBy }),
263
406
  });
264
- await options.store.markPublished(record.id, nowMs(options.clock));
407
+ // The claim's own token goes back with the mark. Without it a relay whose lease lapsed
408
+ // mid-stall retires a row the relay that reclaimed it has not published yet — the row is
409
+ // gone and nothing publishes it.
410
+ await options.store.markPublished(record.id, nowMs(options.clock), record.claimedBy);
265
411
  published += 1;
266
412
  } catch (error) {
267
- // Leave the row unpublished; the next tick retries it. Order is preserved per queue.
413
+ // STOP the batch. `claim()` returns rows in `staged_at` order and the loop used to log
414
+ // and continue, which published every LATER row past the one that failed — so an app
415
+ // that stages `createInvoice` then `chargeCard` in one transaction could have the charge
416
+ // run first. The row stays unpublished and the next tick starts again from it; a
417
+ // permanently poisoned row wedges its queue, which is visible in `pending()` and is the
418
+ // correct trade against silently reordering committed work.
268
419
  logger.warn('jobs.outbox.publish-failed', {
269
420
  job: record.job,
270
421
  id: record.id,
422
+ published,
423
+ remaining: batch.length - published,
271
424
  error: error instanceof Error ? error.message : String(error),
272
425
  });
426
+ // Hand the rest of the batch back rather than sit on a claim nobody is publishing. The
427
+ // claim is a lease now, so without this a single pool timeout parks every committed row
428
+ // behind it for the whole lease window instead of for one poll interval.
429
+ await options.store.release?.(
430
+ batch.slice(published).map((row) => row.id),
431
+ record.claimedBy,
432
+ );
433
+ break;
273
434
  }
274
435
  }
275
436
  return published;
@@ -282,55 +443,39 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
282
443
  timer = setInterval(() => {
283
444
  if (running) return;
284
445
  running = true;
285
- void tick().finally(() => {
286
- running = false;
287
- });
446
+ // `.catch` before `.finally`, the shape every other loop in this package uses. `tick()`
447
+ // guards each publish but not `store.claim()` — one pool timeout during a failover
448
+ // rejects here unobserved, and Bun's default for an unhandled rejection is to end the
449
+ // process, taking every staged, unpublished row with it.
450
+ //
451
+ // Kept rather than discarded, because `stop()` awaits exactly this chain: the publish and
452
+ // the `markPublished` behind it are one pass, and a teardown that returned between them
453
+ // closed the database under the row it was about to mark. The chain carries its own
454
+ // `catch`, so a caller that does not await still gets no unhandled rejection.
455
+ pass = tick()
456
+ .then((): void => undefined)
457
+ .catch((error: unknown) => {
458
+ logger.error('jobs.outbox.tick-failed', {
459
+ error: error instanceof Error ? error.message : String(error),
460
+ });
461
+ })
462
+ .finally(() => {
463
+ running = false;
464
+ pass = undefined;
465
+ });
288
466
  }, intervalMs);
289
467
  },
290
- stop() {
468
+ async stop() {
291
469
  if (timer !== undefined) clearInterval(timer);
292
470
  timer = undefined;
471
+ // Awaited AFTER the interval is cleared, so no further pass can start behind this one.
472
+ await pass;
293
473
  },
294
474
  pending: () => options.store.pendingCount(),
295
475
  };
296
476
  }
297
477
 
298
- /** SQL for the pg-backed outbox. The relay publishes rows this INSERT created. */
299
- export const SQL_OUTBOX_TABLE = `
300
- create table if not exists x_outbox (
301
- id uuid primary key,
302
- job text not null,
303
- queue text not null default 'default',
304
- input jsonb not null,
305
- idempotency_key text not null,
306
- max_attempts int not null default 3,
307
- run_at timestamptz not null default now(),
308
- staged_at timestamptz not null default now(),
309
- tenant_id text,
310
- published_at timestamptz
311
- );
312
- create index if not exists x_outbox_unpublished_idx
313
- on x_outbox (staged_at) where published_at is null;
314
- `.trim();
315
-
316
- export const SQL_OUTBOX_STAGE = `
317
- insert into x_outbox
318
- (id, job, queue, input, idempotency_key, max_attempts, run_at, staged_at, tenant_id)
319
- values ($1, $2, $3, $4::jsonb, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0), $9)
320
- `.trim();
321
-
322
- export const SQL_OUTBOX_CLAIM = `
323
- select id, job, queue, input, idempotency_key, max_attempts,
324
- (extract(epoch from run_at) * 1000)::bigint as run_at,
325
- (extract(epoch from staged_at) * 1000)::bigint as staged_at,
326
- tenant_id
327
- from x_outbox
328
- where published_at is null
329
- order by staged_at
330
- limit $1
331
- for update skip locked
332
- `.trim();
333
-
334
- export const SQL_OUTBOX_MARK_PUBLISHED = `
335
- update x_outbox set published_at = to_timestamp($2 / 1000.0) where id = $1
336
- `.trim();
478
+ // The outbox's SQL moved to `driver-pg-sql.ts`, where every statement this package runs lives —
479
+ // and, more to the point, where `SQL_JOBS_TABLE` is: `x_outbox` was declared here, in a constant
480
+ // no boot code applied, which is the whole reason the outbox was documented and never created.
481
+ // `src/index.ts` still re-exports the same four names, from there.
package/src/register.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { type RegisteredPrimitive, registerPrimitiveRegistrar } from '@ultimat3/core';
8
8
  import { isJobHandle, registerJob } from './job';
9
- import { isTaskHandle, registerTask } from './scheduler';
9
+ import { isTaskHandle, registerTask } from './task';
10
10
 
11
11
  /** `registerJobs(await import('./jobs'))` — export names become job names. */
12
12
  export function registerJobs(
@@ -0,0 +1,35 @@
1
+ // A renewal loop that is TERMINAL once stopped, and the one shape two files renew against.
2
+ // `heartbeat.ts` renews a job's lease and `worker-fleet-slots.ts` a fleet slot, and both decided a
3
+ // LOSS from an answer that arrived after the run had already finished cleanly: `stop()` cleared
4
+ // the interval, which does nothing to the request already on the wire. So a flag is what every
5
+ // branch after an `await` re-reads — the shape `settleWithin`'s `decided` uses in core.
6
+
7
+ export interface RenewalTimer {
8
+ /**
9
+ * True once `stop()` has been called. Read AFTER every await in the renewal body: a clean
10
+ * completion settles the row this renewal is fenced on, so the driver answering "not yours"
11
+ * past that point is a finished job, not a lost lease — and reporting it is an error-level page
12
+ * for a non-event, on exactly the signals that mean the queue re-delivered live work.
13
+ */
14
+ stopped(): boolean;
15
+ /** Stop renewing, for the pass in flight as well as the next one. Idempotent. */
16
+ stop(): void;
17
+ }
18
+
19
+ export function startRenewalTimer(
20
+ intervalMs: number,
21
+ renew: () => void | Promise<void>,
22
+ ): RenewalTimer {
23
+ let stopped = false;
24
+ const timer = setInterval(() => {
25
+ void renew();
26
+ }, intervalMs);
27
+ return {
28
+ stopped: () => stopped,
29
+ stop(): void {
30
+ if (stopped) return;
31
+ stopped = true;
32
+ clearInterval(timer);
33
+ },
34
+ };
35
+ }