@ultimat3/jobs 29.4.0 → 30.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 +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +142 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +90 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- package/src/worker.ts +2 -2
package/src/outbox-pg.ts
CHANGED
|
@@ -16,9 +16,11 @@ import { nowMs } from './clock';
|
|
|
16
16
|
import {
|
|
17
17
|
SQL_OUTBOX_CLAIM,
|
|
18
18
|
SQL_OUTBOX_MARK_PUBLISHED,
|
|
19
|
+
SQL_OUTBOX_MARK_PUBLISHED_MANY,
|
|
19
20
|
SQL_OUTBOX_PENDING_COUNT,
|
|
20
21
|
SQL_OUTBOX_RELEASE,
|
|
21
22
|
SQL_OUTBOX_STAGE,
|
|
23
|
+
SQL_OUTBOX_STAGE_MANY,
|
|
22
24
|
} from './driver-pg-sql';
|
|
23
25
|
import type { OutboxRecord, OutboxStore } from './outbox';
|
|
24
26
|
import { resolveClaimLeaseMs } from './outbox-lease';
|
|
@@ -124,6 +126,29 @@ export function postgresOutboxStore(options: PostgresOutboxStoreOptions): Outbox
|
|
|
124
126
|
staged.set(key(tx), bucket);
|
|
125
127
|
},
|
|
126
128
|
|
|
129
|
+
/** `enqueueMany`'s chunk: one statement on the same connection `stage` writes on. */
|
|
130
|
+
async stageMany(tx, records) {
|
|
131
|
+
if (records.length === 0) return;
|
|
132
|
+
const rows = records.map((record) => ({
|
|
133
|
+
id: record.id,
|
|
134
|
+
runId: record.runId,
|
|
135
|
+
job: record.job,
|
|
136
|
+
queue: record.queue,
|
|
137
|
+
input: record.input ?? null,
|
|
138
|
+
idempotencyKey: record.idempotencyKey,
|
|
139
|
+
maxAttempts: record.maxAttempts,
|
|
140
|
+
runAt: record.runAt,
|
|
141
|
+
stagedAt: record.stagedAt,
|
|
142
|
+
tenantId: record.tenantId,
|
|
143
|
+
traceparent: record.traceparent,
|
|
144
|
+
enqueuedBy: record.enqueuedBy,
|
|
145
|
+
}));
|
|
146
|
+
await options.txExecutor(tx).query(SQL_OUTBOX_STAGE_MANY, [JSON.stringify(rows)]);
|
|
147
|
+
const bucket = staged.get(key(tx)) ?? [];
|
|
148
|
+
bucket.push(...records);
|
|
149
|
+
staged.set(key(tx), bucket);
|
|
150
|
+
},
|
|
151
|
+
|
|
127
152
|
/**
|
|
128
153
|
* Nothing to do but report. The rows became visible when Postgres committed them — there is
|
|
129
154
|
* no second write here, and that absence IS the guarantee: a commit hook that had to run
|
|
@@ -179,6 +204,16 @@ export function postgresOutboxStore(options: PostgresOutboxStoreOptions): Outbox
|
|
|
179
204
|
]);
|
|
180
205
|
},
|
|
181
206
|
|
|
207
|
+
/** The relay's batch mark: one statement, `markPublished`'s fence on every row. */
|
|
208
|
+
async markPublishedMany(ids, at, claimant) {
|
|
209
|
+
if (ids.length === 0) return;
|
|
210
|
+
await options.executor.query(SQL_OUTBOX_MARK_PUBLISHED_MANY, [
|
|
211
|
+
ids,
|
|
212
|
+
at || nowMs(options.clock),
|
|
213
|
+
claimant ?? relayId,
|
|
214
|
+
]);
|
|
215
|
+
},
|
|
216
|
+
|
|
182
217
|
async pendingCount() {
|
|
183
218
|
const rows = await options.executor.query<{ pending: number | string }>(
|
|
184
219
|
SQL_OUTBOX_PENDING_COUNT,
|
package/src/outbox-relay.ts
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
import { assert, logger, onShutdown, renderThrowable } from '@ultimat3/core';
|
|
9
9
|
import { nowMs } from './clock';
|
|
10
10
|
import { createDrainBudget, settleAllBy } from './drain-wait';
|
|
11
|
+
import { publishRequest } from './enqueue-record';
|
|
11
12
|
import { onStaged, signalEnqueued } from './enqueue-signal';
|
|
12
13
|
import { createIdleBackoff } from './idle-backoff';
|
|
13
|
-
import type { OutboxDeps } from './outbox';
|
|
14
|
+
import type { OutboxDeps, OutboxRecord } from './outbox';
|
|
14
15
|
|
|
15
16
|
export interface RelayOptions extends OutboxDeps {
|
|
16
17
|
readonly batchSize?: number;
|
|
@@ -48,19 +49,68 @@ export interface OutboxRelay {
|
|
|
48
49
|
pollDelayMs(): number;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A claimed batch in TWO statements — `driver.enqueueMany`, then `store.markPublishedMany` — where
|
|
54
|
+
* the row loop below spends two per row; `RELAY_BATCH_SIZE` holds the measured figures. Publish,
|
|
55
|
+
* then mark, the row loop's order and for its reason.
|
|
56
|
+
*
|
|
57
|
+
* `false` hands the batch to the row loop: a store without `markPublishedMany`, or a batch that
|
|
58
|
+
* failed anywhere. The loop then publishes in order up to the row that fails and releases the rest, so the order
|
|
59
|
+
* guarantee and the poison row named in `jobs.outbox.publish-failed` are the loop's, unchanged.
|
|
60
|
+
* A batch that published before its mark failed is published again by the loop, by the same ids,
|
|
61
|
+
* which `SQL_ENQUEUE` answers `deduped` without a second row.
|
|
62
|
+
*/
|
|
63
|
+
async function publishBatch(deps: OutboxDeps, batch: readonly OutboxRecord[]): Promise<boolean> {
|
|
64
|
+
const { driver, store } = deps;
|
|
65
|
+
if (store.markPublishedMany === undefined) return false;
|
|
66
|
+
try {
|
|
67
|
+
await driver.enqueueMany(batch.map(publishRequest));
|
|
68
|
+
// One claim stamped every row of the batch, so the first row's token is the batch's.
|
|
69
|
+
const claimant = batch[0]?.claimedBy;
|
|
70
|
+
await store.markPublishedMany(
|
|
71
|
+
batch.map((record) => record.id),
|
|
72
|
+
nowMs(deps.clock),
|
|
73
|
+
claimant,
|
|
74
|
+
);
|
|
75
|
+
return true;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logger.warn('jobs.outbox.batch-fallback', {
|
|
78
|
+
rows: batch.length,
|
|
79
|
+
error: renderThrowable(error),
|
|
80
|
+
});
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Rows one pass claims. 500, not the 100 it was: with the batch path a pass is three statements
|
|
87
|
+
* whatever its size, so the size is the throughput. 10,000 staged rows, three runs each (Postgres,
|
|
88
|
+
* 2026-11-03, a shared box under load): 100 gave 4,259–5,052 rows/s, 500 gave 8,564–9,505. What
|
|
89
|
+
* 500 costs, all measured or bounded:
|
|
90
|
+
*
|
|
91
|
+
* - lock hold: the longest statement of a pass was 45 ms (the insert, whose row locks end at its
|
|
92
|
+
* autocommit); the claim's `skip locked` lock ends with its own statement.
|
|
93
|
+
* - statement size: half of `ENQUEUE_CHUNK`, the bind `enqueueMany` already sends from a request.
|
|
94
|
+
* - the lease: a batch pass took ~60 ms and the row loop about 1 s for 500 rows, both far inside
|
|
95
|
+
* `DEFAULT_OUTBOX_CLAIM_LEASE_MS` (30 s), so no healthy pass loses its claim.
|
|
96
|
+
* - fairness: relays split the backlog by `skip locked`, as before; what grows is a DEAD relay's
|
|
97
|
+
* stranded batch — 500 rows wait out its lease instead of 100, still in staged order.
|
|
98
|
+
*/
|
|
99
|
+
export const RELAY_BATCH_SIZE = 500;
|
|
100
|
+
|
|
51
101
|
/**
|
|
52
102
|
* At-least-once by construction: publish, THEN mark published. A crash between the two
|
|
53
103
|
* re-publishes, which the idempotency key collapses — the opposite order would lose jobs.
|
|
54
104
|
*/
|
|
55
105
|
export function outboxRelay(options: RelayOptions): OutboxRelay {
|
|
56
|
-
const batchSize = options.batchSize ??
|
|
106
|
+
const batchSize = options.batchSize ?? RELAY_BATCH_SIZE;
|
|
57
107
|
const intervalMs = options.intervalMs ?? 200;
|
|
58
108
|
// Refused, never clamped — `worker-options.ts` carries the reason. `setInterval(fn, NaN)` reads
|
|
59
109
|
// the delay as 0 and `claim(NaN)` slices `(0, NaN)`: a relay that spins and publishes nothing.
|
|
60
110
|
assert(
|
|
61
111
|
Number.isSafeInteger(batchSize) && batchSize >= 1,
|
|
62
112
|
`outboxRelay batchSize is ${String(batchSize)} — a batch is a whole number of staged rows, at least one`,
|
|
63
|
-
|
|
113
|
+
`pass a finite batchSize to outboxRelay(...), or omit it for the default ${RELAY_BATCH_SIZE}`,
|
|
64
114
|
);
|
|
65
115
|
assert(
|
|
66
116
|
Number.isFinite(intervalMs) && intervalMs >= 0,
|
|
@@ -97,23 +147,12 @@ export function outboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
97
147
|
const tick = async (): Promise<number> => {
|
|
98
148
|
const batch = await options.store.claim(batchSize);
|
|
99
149
|
let published = 0;
|
|
100
|
-
|
|
150
|
+
if (batch.length > 0 && (await publishBatch(options, batch))) published = batch.length;
|
|
151
|
+
for (const record of published > 0 ? [] : batch) {
|
|
101
152
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
id: record.id,
|
|
106
|
-
runId: record.runId,
|
|
107
|
-
name: record.job,
|
|
108
|
-
queue: record.queue,
|
|
109
|
-
input: record.input,
|
|
110
|
-
idempotencyKey: record.idempotencyKey,
|
|
111
|
-
maxAttempts: record.maxAttempts,
|
|
112
|
-
runAt: record.runAt,
|
|
113
|
-
...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
|
|
114
|
-
...(record.traceparent === undefined ? {} : { traceparent: record.traceparent }),
|
|
115
|
-
...(record.enqueuedBy === undefined ? {} : { enqueuedBy: record.enqueuedBy }),
|
|
116
|
-
});
|
|
153
|
+
// The ids the staging enqueue ANSWERED. Published under them, a repeat of this publish
|
|
154
|
+
// — a crash before the mark below — meets the job this one made rather than adding one.
|
|
155
|
+
await options.driver.enqueue(publishRequest(record));
|
|
117
156
|
// The claim's own token goes back with the mark. Without it a relay whose lease lapsed
|
|
118
157
|
// mid-stall retires a row the relay that reclaimed it has not published yet — the row is
|
|
119
158
|
// gone and nothing publishes it.
|
package/src/outbox.ts
CHANGED
|
@@ -24,21 +24,20 @@
|
|
|
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
|
-
|
|
29
|
-
describeValue,
|
|
30
|
-
traceparent,
|
|
31
|
-
UltimateError,
|
|
32
|
-
uuidV7,
|
|
33
|
-
} from '@ultimat3/core';
|
|
27
|
+
import { uuidV7 } from '@ultimat3/core';
|
|
28
|
+
import { refuseAbandoned } from '@ultimat3/db';
|
|
34
29
|
import type { Tx } from '@ultimat3/entity';
|
|
35
30
|
import { nowMs } from './clock';
|
|
36
31
|
import type { EnqueueResult, JobDriver } from './driver';
|
|
37
|
-
import {
|
|
32
|
+
import { jobDriver } from './driver';
|
|
33
|
+
import { ambientTraceparent, directRequest, namedRunId, stagedRecord } from './enqueue-record';
|
|
38
34
|
import { signalEnqueued, signalStaged } from './enqueue-signal';
|
|
39
35
|
import { DriverUnavailableError, OutboxNoTxError } from './errors';
|
|
40
36
|
import type { JobHandle } from './job';
|
|
37
|
+
import type { EnqueueManyOptions } from './job-enqueue-many';
|
|
38
|
+
import { enqueueMany } from './job-enqueue-many';
|
|
41
39
|
import { resolveClaimLeaseMs } from './outbox-lease';
|
|
40
|
+
import { refuseEnqueueTenant } from './tenant';
|
|
42
41
|
|
|
43
42
|
export interface OutboxRecord {
|
|
44
43
|
/** The outbox row's id AND the id of the job its publish creates: allocated once, at stage. */
|
|
@@ -67,6 +66,11 @@ export interface OutboxRecord {
|
|
|
67
66
|
export interface OutboxStore {
|
|
68
67
|
/** Write inside `tx`. Nothing is visible to the relay until that tx commits. */
|
|
69
68
|
stage(tx: Tx, record: OutboxRecord): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Stage every record in ONE write, in order — `enqueueMany`'s chunk. OPTIONAL, like `release`:
|
|
71
|
+
* absent, the facade calls `stage` once per record, which is correct and slower.
|
|
72
|
+
*/
|
|
73
|
+
stageMany?(tx: Tx, records: readonly OutboxRecord[]): Promise<void>;
|
|
70
74
|
/** Called by the tx runner after COMMIT. */
|
|
71
75
|
commit(tx: Tx): Promise<readonly OutboxRecord[]>;
|
|
72
76
|
/** Called by the tx runner after ROLLBACK. Staged rows vanish with the transaction. */
|
|
@@ -88,6 +92,11 @@ export interface OutboxStore {
|
|
|
88
92
|
release?(ids: readonly string[], claimant?: string): Promise<void>;
|
|
89
93
|
/** `claimant` fences the same way, and here it is worse to miss: this retires the row. */
|
|
90
94
|
markPublished(id: string, at: number, claimant?: string): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* `markPublished` for a whole published batch in one write, under the same fence. OPTIONAL: the
|
|
97
|
+
* relay publishes a batch through `JobDriver.enqueueMany` only when the store has this too.
|
|
98
|
+
*/
|
|
99
|
+
markPublishedMany?(ids: readonly string[], at: number, claimant?: string): Promise<void>;
|
|
91
100
|
pendingCount(): Promise<number>;
|
|
92
101
|
}
|
|
93
102
|
|
|
@@ -154,6 +163,12 @@ export function memoryOutboxStore(options: MemoryOutboxStoreOptions = {}): Memor
|
|
|
154
163
|
staged.set(key(tx), bucket);
|
|
155
164
|
return Promise.resolve();
|
|
156
165
|
},
|
|
166
|
+
stageMany(tx, records) {
|
|
167
|
+
const bucket = staged.get(key(tx)) ?? [];
|
|
168
|
+
bucket.push(...records);
|
|
169
|
+
staged.set(key(tx), bucket);
|
|
170
|
+
return Promise.resolve();
|
|
171
|
+
},
|
|
157
172
|
commit(tx) {
|
|
158
173
|
const bucket = staged.get(key(tx)) ?? [];
|
|
159
174
|
staged.delete(key(tx));
|
|
@@ -196,6 +211,14 @@ export function memoryOutboxStore(options: MemoryOutboxStoreOptions = {}): Memor
|
|
|
196
211
|
claims.delete(id);
|
|
197
212
|
return Promise.resolve();
|
|
198
213
|
},
|
|
214
|
+
markPublishedMany(ids, _at, claimant) {
|
|
215
|
+
for (const id of ids) {
|
|
216
|
+
if (!owns(id, claimant)) continue;
|
|
217
|
+
committed.delete(id);
|
|
218
|
+
claims.delete(id);
|
|
219
|
+
}
|
|
220
|
+
return Promise.resolve();
|
|
221
|
+
},
|
|
199
222
|
pendingCount() {
|
|
200
223
|
let count = 0;
|
|
201
224
|
for (const record of committed.values()) {
|
|
@@ -228,43 +251,6 @@ export interface EnqueueOptions {
|
|
|
228
251
|
readonly runId?: string;
|
|
229
252
|
}
|
|
230
253
|
|
|
231
|
-
/**
|
|
232
|
-
* The W3C `traceparent` of the span this enqueue is happening inside, or `undefined` outside a
|
|
233
|
-
* trace. This is the ONE place the link is minted: `docs/idea/04-jobs.md` promises a job trace
|
|
234
|
-
* linked to the enqueuing request, and before this there was no field to carry the link, so a
|
|
235
|
-
* checkout's `chargeCard` opened a fresh root two seconds later with nothing pointing back.
|
|
236
|
-
*
|
|
237
|
-
* A context recovered from a `Ctx` has an empty `spanId` (`currentSpanContext`), which renders as
|
|
238
|
-
* an all-zero parent that every collector rejects — so that case carries no header rather than a
|
|
239
|
-
* malformed one, and the job opens a root as it did before.
|
|
240
|
-
*/
|
|
241
|
-
function ambientTraceparent(): string | undefined {
|
|
242
|
-
const context = currentSpanContext();
|
|
243
|
-
if (context === undefined || context.spanId === '') return undefined;
|
|
244
|
-
return traceparent(context);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** What Postgres prints for a `uuid`, and so the only spelling both drivers store identically. */
|
|
248
|
-
const RUN_ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* `EnqueueOptions.runId`, refused HERE when it is not a uuid — the one screen both enqueue paths
|
|
252
|
-
* read. `run_id` is a `uuid` column: the memory driver took any string, Postgres raised a raw
|
|
253
|
-
* `22P02`, and on the staged path that was the caller's whole transaction failing at the outbox
|
|
254
|
-
* insert. Any version, lowercase: an uppercase one would come back from Postgres re-spelled.
|
|
255
|
-
*/
|
|
256
|
-
function namedRunId(job: string, runId: unknown): string | undefined {
|
|
257
|
-
if (runId === undefined) return undefined;
|
|
258
|
-
if (typeof runId === 'string' && RUN_ID_SHAPE.test(runId)) return runId;
|
|
259
|
-
throw new UltimateError({
|
|
260
|
-
code: 'X_ID_INVALID',
|
|
261
|
-
// `describeValue`, never the value: what arrives here wrong is as often a key as a typo.
|
|
262
|
-
cause: `enqueue of job "${job}" was given runId ${describeValue(runId)}, and a run id is a lowercase uuid (8-4-4-4-12 hex) — the queue stores it in a uuid column`,
|
|
263
|
-
fix: "pass { runId: uuidV7() } to enqueue(), with import { uuidV7 } from '@ultimat3/core'",
|
|
264
|
-
meta: { job, option: 'runId' },
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
254
|
export interface OutboxDeps {
|
|
269
255
|
readonly store: OutboxStore;
|
|
270
256
|
readonly driver: JobDriver;
|
|
@@ -273,7 +259,13 @@ export interface OutboxDeps {
|
|
|
273
259
|
readonly mode?: 'default' | 'required';
|
|
274
260
|
}
|
|
275
261
|
|
|
276
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* Stage a job inside `tx`. Returns the row that the relay will publish after commit.
|
|
264
|
+
*
|
|
265
|
+
* The facade's staged arm, and NOT in `index.ts`: the refusals (`refuseAbandoned`, the concurrency
|
|
266
|
+
* key, `refuseEnqueueTenant`) are the facade's and precede this call, so a public spelling of it
|
|
267
|
+
* was a second enqueue path that staged rows the facade refuses. Exported for this package's tests.
|
|
268
|
+
*/
|
|
277
269
|
export async function enqueueInTx<I>(
|
|
278
270
|
deps: OutboxDeps,
|
|
279
271
|
tx: Tx,
|
|
@@ -282,24 +274,13 @@ export async function enqueueInTx<I>(
|
|
|
282
274
|
options: EnqueueOptions = {},
|
|
283
275
|
): Promise<OutboxRecord> {
|
|
284
276
|
const runId = namedRunId(handle.name, options.runId);
|
|
285
|
-
const
|
|
286
|
-
const trace = options.traceparent ?? ambientTraceparent();
|
|
287
|
-
const record: OutboxRecord = {
|
|
277
|
+
const record = stagedRecord(handle, input, options, {
|
|
288
278
|
id: uuidV7(),
|
|
289
279
|
runId: runId ?? uuidV7(),
|
|
290
|
-
job: handle.name,
|
|
291
|
-
queue: options.queue ?? handle.queue ?? DEFAULT_QUEUE,
|
|
292
|
-
input,
|
|
293
280
|
idempotencyKey: handle.idempotencyKeyFor(input),
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
|
|
298
|
-
// Stamped at STAGE time and not at publish time: the relay runs after commit, in its own
|
|
299
|
-
// timer, with no request span in scope — a trace read there would be nobody's.
|
|
300
|
-
...(trace === undefined ? {} : { traceparent: trace }),
|
|
301
|
-
...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
|
|
302
|
-
};
|
|
281
|
+
at: nowMs(deps.clock),
|
|
282
|
+
trace: options.traceparent ?? ambientTraceparent(),
|
|
283
|
+
});
|
|
303
284
|
await deps.store.stage(tx, record);
|
|
304
285
|
// Wakes a relay in THIS process that has backed off; a relay elsewhere polls on its own.
|
|
305
286
|
signalStaged();
|
|
@@ -312,6 +293,12 @@ export interface JobsFacade {
|
|
|
312
293
|
* a job, or a script — the transactional behaviour is the framework's problem, not yours.
|
|
313
294
|
*/
|
|
314
295
|
enqueue<I>(handle: JobHandle<I>, input: I, options?: EnqueueOptions): Promise<EnqueueResult>;
|
|
296
|
+
/** Many of one job, one statement per chunk, answered in input order (`job-enqueue-many.ts`). */
|
|
297
|
+
enqueueMany<I>(
|
|
298
|
+
handle: JobHandle<I>,
|
|
299
|
+
inputs: readonly I[],
|
|
300
|
+
options?: EnqueueManyOptions,
|
|
301
|
+
): Promise<readonly EnqueueResult[]>;
|
|
315
302
|
}
|
|
316
303
|
|
|
317
304
|
export function outboxJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefined): JobsFacade {
|
|
@@ -321,10 +308,15 @@ export function outboxJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
|
|
|
321
308
|
input: I,
|
|
322
309
|
options: EnqueueOptions = {},
|
|
323
310
|
): Promise<EnqueueResult> {
|
|
311
|
+
// A direct `driver.enqueue` is a write the transaction guard cannot see: a scope whose caller
|
|
312
|
+
// was already answered starts no job, on either path below.
|
|
313
|
+
refuseAbandoned();
|
|
324
314
|
// Asked before anything is staged, and for its refusal alone: a `concurrency.key` that
|
|
325
315
|
// answers an empty string is one lock shared by every run of the job, and the caller of
|
|
326
316
|
// this enqueue is the only one who can still be told (`X_JOB_DECLARATION_INVALID`).
|
|
327
317
|
handle.concurrencyKeyFor(input);
|
|
318
|
+
// A payload naming one org, enqueued for — or by an actor of — another (`tenant.ts`).
|
|
319
|
+
refuseEnqueueTenant(handle.name, handle.tenantFor(input), options.tenantId);
|
|
328
320
|
const tx = options.outbox === false ? undefined : currentTx();
|
|
329
321
|
|
|
330
322
|
if (tx === undefined) {
|
|
@@ -333,18 +325,14 @@ export function outboxJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
|
|
|
333
325
|
}
|
|
334
326
|
const runId = namedRunId(handle.name, options.runId);
|
|
335
327
|
const trace = options.traceparent ?? ambientTraceparent();
|
|
336
|
-
const result = await deps.driver.enqueue(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
|
|
345
|
-
...(trace === undefined ? {} : { traceparent: trace }),
|
|
346
|
-
...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
|
|
347
|
-
});
|
|
328
|
+
const result = await deps.driver.enqueue(
|
|
329
|
+
directRequest(handle, input, options, {
|
|
330
|
+
idempotencyKey: handle.idempotencyKeyFor(input),
|
|
331
|
+
at: nowMs(deps.clock),
|
|
332
|
+
trace,
|
|
333
|
+
...(runId === undefined ? {} : { runId }),
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
348
336
|
// A worker in this process that has backed off starts its next pass now.
|
|
349
337
|
signalEnqueued(options.queue ?? handle.queue);
|
|
350
338
|
return result;
|
|
@@ -357,6 +345,10 @@ export function outboxJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
|
|
|
357
345
|
// LIVE job holds creates nothing, and these ids then name no row.
|
|
358
346
|
return { id: record.id, runId: record.runId, deduped: false };
|
|
359
347
|
},
|
|
348
|
+
enqueueMany<I>(handle: JobHandle<I>, inputs: readonly I[], options: EnqueueManyOptions = {}) {
|
|
349
|
+
const tx = options.outbox === false ? undefined : currentTx();
|
|
350
|
+
return enqueueMany(deps, tx, handle, inputs, options);
|
|
351
|
+
},
|
|
360
352
|
};
|
|
361
353
|
}
|
|
362
354
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Single responsibility: an entity's `retain` as a `PurgeTarget` — the batched, cross-tenant delete
|
|
2
|
+
// of every row older than its window — and the record of which entities a `purge()` covers, which
|
|
3
|
+
// is what `x verify` reads to refuse a retention promise nothing keeps (`X_ENTITY_RETAIN_UNSWEPT`).
|
|
4
|
+
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
CROSS_TENANT_SETTING,
|
|
8
|
+
identifier,
|
|
9
|
+
PURGE_SETTING,
|
|
10
|
+
setLocal,
|
|
11
|
+
sql,
|
|
12
|
+
withTransaction,
|
|
13
|
+
} from '@ultimat3/db';
|
|
14
|
+
import { type EntityCore, registeredEntities } from '@ultimat3/entity';
|
|
15
|
+
import type { PurgeTarget } from './purge';
|
|
16
|
+
import { type PurgeBatching, purgeBatching, purgeInBatches } from './purge-batches';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The sweep of one `retain` entity. Each batch is its own transaction that names this table in
|
|
20
|
+
* `x.purge` — the one thing a history table's guard lets delete — and opens `x.cross`: retention is
|
|
21
|
+
* every tenant's, and under forced row security an unset tenant would match no row at all.
|
|
22
|
+
*/
|
|
23
|
+
export function retainTarget(entity: EntityCore, options: PurgeBatching = {}): PurgeTarget {
|
|
24
|
+
const retain = entity.$retain;
|
|
25
|
+
assert(
|
|
26
|
+
retain !== null,
|
|
27
|
+
`retainTarget(${entity.$name}) — the entity declares no retain, so nothing says which rows expire`,
|
|
28
|
+
`add retain: { after: '90d', on: 'createdAt' } to entity('${entity.$name}'), then x db gen "retention"`,
|
|
29
|
+
);
|
|
30
|
+
const bounds = purgeBatching(`retainTarget(${entity.$name})`, options);
|
|
31
|
+
const table = identifier(entity.$table);
|
|
32
|
+
const column = identifier(retain.column);
|
|
33
|
+
return {
|
|
34
|
+
name: entity.$table,
|
|
35
|
+
purgeExpired: (nowMs) => {
|
|
36
|
+
const cutoff = new Date(nowMs - retain.afterMs);
|
|
37
|
+
return purgeInBatches(
|
|
38
|
+
(size) =>
|
|
39
|
+
withTransaction(async (tx) => {
|
|
40
|
+
await setLocal(tx, { [PURGE_SETTING]: entity.$table, [CROSS_TENANT_SETTING]: 'on' });
|
|
41
|
+
const row = await tx.one<{ removed: number | string }>(
|
|
42
|
+
sql`with gone as (delete from ${table} where ctid in (select ctid from ${table} where ${column} < ${cutoff} limit ${size}) returning 1) select count(*) as removed from gone`,
|
|
43
|
+
);
|
|
44
|
+
return Number(row?.removed ?? 0);
|
|
45
|
+
}),
|
|
46
|
+
bounds,
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every registered entity that declares `retain`, history tables included, by name. */
|
|
53
|
+
export function retainedEntities(): readonly EntityCore[] {
|
|
54
|
+
return registeredEntities().flatMap((entry) =>
|
|
55
|
+
entry.core !== undefined && entry.core.$retain !== null ? [entry.core] : [],
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let coversAll = false;
|
|
60
|
+
const covered = new Set<string>();
|
|
61
|
+
|
|
62
|
+
/** What `purge({ entities })` declares it sweeps: `'declared'`, or the entities it names. */
|
|
63
|
+
export type PurgeEntities = 'declared' | readonly EntityCore[];
|
|
64
|
+
|
|
65
|
+
/** Recorded at `purge()`, read by `x verify`. */
|
|
66
|
+
export function recordPurgeCoverage(entities: PurgeEntities | undefined): void {
|
|
67
|
+
if (entities === undefined) return;
|
|
68
|
+
if (entities === 'declared') coversAll = true;
|
|
69
|
+
else for (const entity of entities) covered.add(entity.$name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The retain entities no `purge()` covers, sorted. */
|
|
73
|
+
export function unsweptEntities(): readonly string[] {
|
|
74
|
+
if (coversAll) return [];
|
|
75
|
+
return retainedEntities()
|
|
76
|
+
.map((entity) => entity.$name)
|
|
77
|
+
.filter((name) => !covered.has(name))
|
|
78
|
+
.sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The targets `entities` resolves to, read at each attempt as the definition's `targets` is. */
|
|
82
|
+
export function entityTargets(entities: PurgeEntities | undefined): readonly PurgeTarget[] {
|
|
83
|
+
if (entities === undefined) return [];
|
|
84
|
+
const list = entities === 'declared' ? retainedEntities() : entities;
|
|
85
|
+
return list.map((entity) => retainTarget(entity));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Test seam: forget what every `purge()` declared. */
|
|
89
|
+
export function resetPurgeCoverage(): void {
|
|
90
|
+
coversAll = false;
|
|
91
|
+
covered.clear();
|
|
92
|
+
}
|
package/src/purge.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { DurationInput } from './clock';
|
|
|
18
18
|
import { nowMs } from './clock';
|
|
19
19
|
import type { JobHandle } from './job';
|
|
20
20
|
import { job } from './job';
|
|
21
|
+
import { entityTargets, type PurgeEntities, recordPurgeCoverage } from './purge-entities';
|
|
21
22
|
import type { RetryPolicy } from './retry';
|
|
22
23
|
import { DEFAULT_RETRY } from './retry';
|
|
23
24
|
|
|
@@ -83,7 +84,13 @@ export interface PurgeDefinition {
|
|
|
83
84
|
* does not exist until later. An empty list is a pass that removes nothing, which is the honest
|
|
84
85
|
* answer for a process whose boot has already stopped.
|
|
85
86
|
*/
|
|
86
|
-
targets(): readonly PurgeTarget[];
|
|
87
|
+
targets?(): readonly PurgeTarget[];
|
|
88
|
+
/**
|
|
89
|
+
* Entities whose `retain` this sweep keeps, beside `targets`: `'declared'` is every registered
|
|
90
|
+
* entity declaring one, history tables included (`purge-entities.ts`). Read per attempt, as
|
|
91
|
+
* `targets` is. What `x verify` reads to refuse a `retain` no purge covers.
|
|
92
|
+
*/
|
|
93
|
+
readonly entities?: PurgeEntities;
|
|
87
94
|
/**
|
|
88
95
|
* The clock every target is measured against. Defaults to the system clock, and it must be the
|
|
89
96
|
* SAME clock the stores write their instants from — see `PurgeTarget.purgeExpired`.
|
|
@@ -100,6 +107,7 @@ export const DEFAULT_PURGE_CRON = '23 * * * *';
|
|
|
100
107
|
|
|
101
108
|
export function purge(definition: PurgeDefinition): JobHandle<PurgeInput> {
|
|
102
109
|
const clock = definition.clock;
|
|
110
|
+
recordPurgeCoverage(definition.entities);
|
|
103
111
|
|
|
104
112
|
return job<PurgeInput>({
|
|
105
113
|
...(definition.name === undefined ? {} : { name: definition.name }),
|
|
@@ -119,7 +127,7 @@ export function purge(definition: PurgeDefinition): JobHandle<PurgeInput> {
|
|
|
119
127
|
// against instants a round trip apart, which is the same class of mismatch as reading the
|
|
120
128
|
// server's clock — smaller, and just as unnecessary.
|
|
121
129
|
const at = nowMs(clock);
|
|
122
|
-
const targets = definition.targets();
|
|
130
|
+
const targets = [...(definition.targets?.() ?? []), ...entityTargets(definition.entities)];
|
|
123
131
|
const names = new Set(targets.map((target) => target.name));
|
|
124
132
|
// Refused before the first delete, not discovered at the second step: `step.run` raises
|
|
125
133
|
// `X_STEP_DUPLICATE` on the repeat, which dead-letters a sweep AFTER it has already emptied
|
package/src/queue-wake.ts
CHANGED
|
@@ -49,7 +49,7 @@ const CHANNELS = [JOBS_WAKE_CHANNEL, OUTBOX_WAKE_CHANNEL] as const;
|
|
|
49
49
|
type Channel = (typeof CHANNELS)[number];
|
|
50
50
|
|
|
51
51
|
const UNVERIFIED_FIX =
|
|
52
|
-
'point DATABASE_URL at Postgres itself or at a SESSION-pooling proxy (PgBouncer pool_mode = session) for the worker role — a transaction-pooling proxy delivers no notification. Nothing is lost meanwhile: jobs start on the poll, at most idlePollMaxMs late';
|
|
52
|
+
'point DATABASE_URL at Postgres itself or at a SESSION-pooling proxy (PgBouncer pool_mode = session) for the worker role, and unset DATABASE_POOLER for that role — a transaction-pooling proxy delivers no notification. Nothing is lost meanwhile: jobs start on the poll, at most idlePollMaxMs late';
|
|
53
53
|
|
|
54
54
|
const armTimer = (run: () => void, delayMs: number): (() => void) => {
|
|
55
55
|
const timer = setTimeout(run, delayMs);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `refreshView()` — recompute a materialized view, declared as a `job` and never as a ninth
|
|
2
|
+
// primitive. A view over an arbitrary join is refreshed WHOLE; a time window over append-mostly
|
|
3
|
+
// rows is `rollup()`'s, which recomputes only recent buckets. The schedule is the app's `task`.
|
|
4
|
+
|
|
5
|
+
import { db, identifier, sql } from '@ultimat3/db';
|
|
6
|
+
import type { EntityCore } from '@ultimat3/entity';
|
|
7
|
+
import { t } from '@ultimat3/schema';
|
|
8
|
+
import type { DurationInput } from './clock';
|
|
9
|
+
import type { JobHandle } from './job';
|
|
10
|
+
import { job } from './job';
|
|
11
|
+
import { DEFAULT_RETRY, type RetryPolicy } from './retry';
|
|
12
|
+
import { RefreshViewInvalidError } from './rollup-errors';
|
|
13
|
+
|
|
14
|
+
export interface RefreshViewDefinition {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
/** An entity declared `view: { sql, materialized: true }`. */
|
|
17
|
+
readonly view: EntityCore;
|
|
18
|
+
/**
|
|
19
|
+
* `refresh … concurrently`: readers keep the old rows while the new ones are computed, at the
|
|
20
|
+
* price of a unique index on the view (`.unique()` on a column), refused here without one.
|
|
21
|
+
*/
|
|
22
|
+
readonly concurrently?: boolean;
|
|
23
|
+
readonly queue?: string;
|
|
24
|
+
readonly retry?: RetryPolicy;
|
|
25
|
+
readonly timeout?: DurationInput;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What one run reports: the view, and whether readers were blocked while it ran. */
|
|
29
|
+
export interface RefreshViewReport {
|
|
30
|
+
readonly view: string;
|
|
31
|
+
readonly concurrently: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const assertRefreshable = (definition: RefreshViewDefinition): boolean => {
|
|
35
|
+
const { name, view } = definition;
|
|
36
|
+
if (view.$viewQuery?.materialized !== true) {
|
|
37
|
+
throw new RefreshViewInvalidError({
|
|
38
|
+
job: name,
|
|
39
|
+
problem: `names ${view.$name}, which is ${view.$viewQuery === null ? 'a table' : 'a plain view, recomputed on every read'} — only a materialized view stores rows to refresh`,
|
|
40
|
+
fix: `entity('${view.$name}', { view: { sql, materialized: true }, columns }) # then x db gen`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const concurrently = definition.concurrently === true;
|
|
44
|
+
if (concurrently && !view.$indexes.some((index) => index.unique && index.where === undefined)) {
|
|
45
|
+
throw new RefreshViewInvalidError({
|
|
46
|
+
job: name,
|
|
47
|
+
problem: `refreshes ${view.$name} concurrently, and Postgres needs a unique index on the view to do that`,
|
|
48
|
+
fix: `entity('${view.$name}', { view, columns: { <key>: uuid().unique(), … } }) # or refreshView({ name: '${name}', view, concurrently: false })`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return concurrently;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function refreshView(definition: RefreshViewDefinition): JobHandle<Record<string, never>> {
|
|
55
|
+
const concurrently = assertRefreshable(definition);
|
|
56
|
+
const target = identifier(definition.view.$table);
|
|
57
|
+
const statement = concurrently
|
|
58
|
+
? sql`refresh materialized view concurrently ${target}`
|
|
59
|
+
: sql`refresh materialized view ${target}`;
|
|
60
|
+
return job<Record<string, never>, RefreshViewReport>({
|
|
61
|
+
name: definition.name,
|
|
62
|
+
input: t.object({}),
|
|
63
|
+
// One refresh at a time: a second enqueue while one runs would compute the same rows again.
|
|
64
|
+
idempotencyKey: () => `refresh-view:${definition.name}`,
|
|
65
|
+
// A materialized view spans every tenant's rows; the read path scopes them, not the refresh.
|
|
66
|
+
tenant: 'none',
|
|
67
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
68
|
+
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
69
|
+
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
70
|
+
run: ({ step }) =>
|
|
71
|
+
step.run('refresh', async () => {
|
|
72
|
+
await db().execute(statement);
|
|
73
|
+
return { view: definition.view.$name, concurrently };
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
}
|