@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.
- package/CLAUDE.md +660 -0
- package/README.md +432 -17
- package/package.json +7 -5
- package/src/backfill-gate.ts +97 -0
- package/src/backfill-inspect.ts +73 -0
- package/src/backfill-ledger.ts +183 -0
- package/src/backfill-pass.ts +276 -0
- package/src/backfill-pending.ts +131 -0
- package/src/backfill-rate.ts +109 -0
- package/src/backfill-registry.ts +108 -0
- package/src/backfill-scope.ts +70 -0
- package/src/backfill.ts +213 -0
- package/src/driver-memory.ts +61 -9
- package/src/driver-nats.ts +2 -1
- package/src/driver-pg-ddl.ts +191 -0
- package/src/driver-pg-rows.ts +123 -0
- package/src/driver-pg-sql.ts +312 -55
- package/src/driver-pg.ts +138 -92
- package/src/driver-redis.ts +2 -1
- package/src/driver.ts +91 -7
- package/src/errors.ts +314 -5
- package/src/events-pg.ts +121 -0
- package/src/events.ts +7 -1
- package/src/execute.ts +308 -0
- package/src/heartbeat.ts +148 -0
- package/src/index.ts +128 -27
- package/src/inspect.ts +43 -2
- package/src/job.ts +127 -3
- package/src/leases.ts +90 -0
- package/src/limits.ts +0 -0
- package/src/metrics.ts +35 -0
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +188 -0
- package/src/outbox.ts +204 -59
- package/src/register.ts +1 -1
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/retry.ts +6 -1
- package/src/run-signal.ts +50 -0
- package/src/scheduler-pg.ts +103 -0
- package/src/scheduler.ts +159 -245
- package/src/steps.ts +155 -31
- package/src/task.ts +239 -0
- package/src/tenant.ts +61 -0
- package/src/worker-fleet-slots.ts +129 -0
- package/src/worker-run.ts +132 -0
- 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
|
+
}
|
package/src/outbox-pg.ts
ADDED
|
@@ -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
|
|
2
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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():
|
|
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(
|
|
167
|
+
.filter((record) => record.publishedAt === undefined && free(record.id, at))
|
|
168
|
+
.sort(byClaimOrder)
|
|
80
169
|
.slice(0, limit);
|
|
81
|
-
|
|
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,
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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 './
|
|
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
|
+
}
|