@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.
Files changed (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. 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,
@@ -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 ?? 100;
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
- 'pass a finite batchSize to outboxRelay(...), or omit it for the default 100',
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
- for (const record of batch) {
150
+ if (batch.length > 0 && (await publishBatch(options, batch))) published = batch.length;
151
+ for (const record of published > 0 ? [] : batch) {
101
152
  try {
102
- await options.driver.enqueue({
103
- // The ids the staging enqueue ANSWERED. Published under them, a repeat of this publish
104
- // — a crash before the mark below — meets the job this one made rather than adding one.
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
- currentSpanContext,
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 { DEFAULT_QUEUE, jobDriver } from './driver';
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
- /** Stage a job inside `tx`. Returns the row that the relay will publish after commit. */
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 at = nowMs(deps.clock);
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
- maxAttempts: handle.retry.attempts,
295
- runAt: options.runAt ?? at,
296
- stagedAt: at,
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
- name: handle.name,
338
- queue: options.queue ?? handle.queue,
339
- input,
340
- idempotencyKey: handle.idempotencyKeyFor(input),
341
- maxAttempts: handle.retry.attempts,
342
- runAt: options.runAt ?? nowMs(deps.clock),
343
- ...(runId === undefined ? {} : { runId }),
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
+ }