@ultimat3/jobs 23.0.0 → 25.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 (70) hide show
  1. package/CLAUDE.md +72 -75
  2. package/README.md +83 -38
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +25 -3
  6. package/src/backfill-pass.ts +22 -5
  7. package/src/backfill-rate.ts +2 -2
  8. package/src/backfill.ts +2 -2
  9. package/src/claim-exhausted.ts +76 -0
  10. package/src/counters.ts +1 -1
  11. package/src/driver-memory-operator.ts +34 -12
  12. package/src/driver-memory.ts +80 -38
  13. package/src/driver-pg-jobs-sql.ts +27 -19
  14. package/src/driver-pg-operator-sql.ts +32 -12
  15. package/src/driver-pg-operator.ts +19 -3
  16. package/src/driver-pg-rows.ts +3 -3
  17. package/src/driver-pg-settle-sql.ts +2 -3
  18. package/src/driver-pg-sql.ts +99 -30
  19. package/src/driver-pg.ts +103 -98
  20. package/src/driver.ts +46 -16
  21. package/src/errors-concurrency.ts +1 -1
  22. package/src/errors-operator.ts +12 -4
  23. package/src/errors-requeue.ts +21 -3
  24. package/src/errors.ts +9 -17
  25. package/src/events-pg.ts +11 -21
  26. package/src/events.ts +26 -15
  27. package/src/execute.ts +47 -5
  28. package/src/export.ts +2 -2
  29. package/src/heartbeat.ts +7 -2
  30. package/src/index.ts +45 -99
  31. package/src/inspect-operator.ts +4 -4
  32. package/src/inspect.ts +5 -4
  33. package/src/introspection.ts +23 -11
  34. package/src/job.ts +6 -7
  35. package/src/leases.ts +1 -1
  36. package/src/limits.ts +46 -15
  37. package/src/outbox-lease.ts +1 -1
  38. package/src/outbox-pg.ts +6 -7
  39. package/src/outbox-relay.ts +6 -6
  40. package/src/outbox.ts +47 -19
  41. package/src/purge.ts +1 -1
  42. package/src/queue-wake.ts +1 -2
  43. package/src/renewal-timer.ts +1 -1
  44. package/src/retry-classification.ts +52 -15
  45. package/src/scheduler-leader.ts +2 -2
  46. package/src/scheduler-pg.ts +19 -16
  47. package/src/scheduler-state.ts +10 -3
  48. package/src/scheduler.ts +14 -15
  49. package/src/steps-memory.ts +2 -2
  50. package/src/steps-suspension.ts +46 -0
  51. package/src/steps-timeout.ts +33 -0
  52. package/src/steps.ts +90 -88
  53. package/src/task.ts +1 -1
  54. package/src/webhook-attempt.ts +206 -0
  55. package/src/webhook-ledger.ts +15 -4
  56. package/src/webhook.ts +64 -118
  57. package/src/worker-admit.ts +14 -6
  58. package/src/worker-drain-cutoff.ts +110 -0
  59. package/src/worker-fleet-slots.ts +82 -45
  60. package/src/worker-held.ts +100 -0
  61. package/src/worker-key-busy.ts +38 -10
  62. package/src/worker-loop.ts +35 -11
  63. package/src/worker-options.ts +19 -15
  64. package/src/worker-registry.ts +16 -1
  65. package/src/worker-run.ts +38 -8
  66. package/src/worker-tally.ts +53 -0
  67. package/src/worker-types.ts +7 -4
  68. package/src/worker.ts +124 -73
  69. package/src/driver-nats.ts +0 -86
  70. package/src/driver-redis.ts +0 -89
@@ -68,7 +68,12 @@ export interface JobFilter {
68
68
  readonly tenantId?: string;
69
69
  }
70
70
 
71
- const CURSOR = /^(\d{1,16}):([0-9A-Za-z-]{1,64})$/;
71
+ /**
72
+ * `<createdAt ms>:<job id>`, and the id is a UUID in its one canonical shape. The group accepted
73
+ * any 1-64 letters, digits and hyphens, which the memory driver compared as text and the pg
74
+ * statement CAST: `123:not-a-uuid` was an empty page here and a raw `22P02` there.
75
+ */
76
+ const CURSOR = /^(\d{1,16}):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
72
77
 
73
78
  /** The cursor a page's LAST row yields. A page shorter than its limit is the last page. */
74
79
  export function jobCursor(record: Pick<JobRecord, 'createdAt' | 'id'>): string {
@@ -76,11 +81,12 @@ export function jobCursor(record: Pick<JobRecord, 'createdAt' | 'id'>): string {
76
81
  }
77
82
 
78
83
  export function parseJobCursor(
79
- after: string,
84
+ raw: string,
80
85
  subject = 'the job list',
86
+ cursor: 'after' | 'before' = 'after',
81
87
  ): { readonly createdAt: number; readonly id: string } {
82
- const match = CURSOR.exec(after);
83
- if (match === null) throw new JobPageInvalidError({ subject, maxPage: MAX_JOB_PAGE });
88
+ const match = CURSOR.exec(raw);
89
+ if (match === null) throw new JobPageInvalidError({ subject, maxPage: MAX_JOB_PAGE, cursor });
84
90
  return { createdAt: Number(match[1]), id: match[2] ?? '' };
85
91
  }
86
92
 
@@ -108,7 +114,11 @@ export const PROMOTABLE_STATES: ReadonlySet<JobState> = new Set<JobState>(['read
108
114
  export interface BulkResult {
109
115
  /** Rows this call changed — at most `MAX_BULK_ROWS`. */
110
116
  readonly affected: number;
111
- /** Rows still matching the filter afterwards. Call again until it is zero. */
117
+ /**
118
+ * Rows the SAME call would still act on afterwards — so "call again until it is zero" ends. A
119
+ * row the verb leaves where it is (a requeue whose key a live job holds, a promote of a row
120
+ * already due) is not counted: it will be skipped on every later call too.
121
+ */
112
122
  readonly remaining: number;
113
123
  }
114
124
 
@@ -183,16 +193,17 @@ export interface JobIntrospection {
183
193
  list(filter?: JobFilter): Promise<readonly JobRecord[]>;
184
194
  deadLetters(limit?: number): Promise<readonly JobRecord[]>;
185
195
  /**
186
- * Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`, and a
187
- * key a live job holds is `X_JOB_DUPLICATE`. `fromStep` drops that step and every step that
188
- * started after it.
196
+ * Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`, a key a
197
+ * live job holds is `X_JOB_DUPLICATE`, and an id nobody queued is `X_JOB_NOT_FOUND`. `fromStep`
198
+ * drops that step and every step that started after it, with the row's own move.
189
199
  */
190
200
  requeue(jobId: string, options?: { readonly fromStep?: string }): Promise<JobRecord>;
191
201
  /**
192
202
  * Stop a job from outside. Terminal for a queued row immediately; a RUNNING one stops at its
193
203
  * next heartbeat, which no longer matches its own row and cancels the attempt. Answers
194
- * `undefined` for a job id it does not hold or one that already finished. Optional, as it
195
- * shipped: a driver may have no way to address a single running row.
204
+ * `undefined` for a job id it does not hold or one that is not LIVE (`LIVE_STATES`) — a done,
205
+ * failed, dead or already cancelled row is left exactly as it ended. Optional, as it shipped: a
206
+ * driver may have no way to address a single running row.
196
207
  */
197
208
  cancel?(jobId: string, reason?: string): Promise<JobRecord | undefined>;
198
209
  /**
@@ -203,7 +214,8 @@ export interface JobIntrospection {
203
214
  remove(jobId: string): Promise<JobRecord | undefined>;
204
215
  /**
205
216
  * Re-queue up to `MAX_BULK_ROWS` finished jobs. `filter.state` must be a requeueable state. A
206
- * row whose key a live job holds is left where it is and counted in `remaining`.
217
+ * row whose key a live job holds is left where it is and is NOT counted in `remaining`; of
218
+ * several finished rows sharing one key, the oldest is the one requeued.
207
219
  */
208
220
  requeueMany(filter: BulkFilter): Promise<BulkResult>;
209
221
  /** Delete up to `MAX_BULK_ROWS` jobs. `filter.state` may be any state but `running`. */
package/src/job.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // deletes that class of bug: there is no way to define a job that cannot be deduped.
9
9
 
10
10
  import type { Ctx } from '@ultimat3/core';
11
- import { assert } from '@ultimat3/core';
11
+ import { assert, finiteCount } from '@ultimat3/core';
12
12
  import type { StandardSchemaV1 } from '@ultimat3/schema';
13
13
  import { parse } from '@ultimat3/schema';
14
14
  import type { DurationInput } from './clock';
@@ -101,7 +101,7 @@ export interface JobDefinition<I, R = unknown> {
101
101
  *
102
102
  * Enforced by `JobDriver.leases` — a row every replica can see — and NOT by `limits.ts`, which
103
103
  * counts one process's heap. A driver with no lease store cannot hold this cap, so
104
- * `createWorker().start()` refuses to boot rather than let it pass silently
104
+ * `jobWorker().start()` refuses to boot rather than let it pass silently
105
105
  * (`X_JOB_CONCURRENCY_UNENFORCEABLE`): this field was declared, documented and in the manifest
106
106
  * while nothing read it, which is exactly what axiom 3 exists to prevent.
107
107
  */
@@ -266,11 +266,10 @@ export function job<I, R = unknown>(definition: JobDefinition<I, R>): JobHandle<
266
266
  throw new JobDeclarationInvalidError({ job: name, missing });
267
267
  }
268
268
  assertJobTenant(name, definition.tenant);
269
- assert(
270
- definition.retry.attempts >= 1,
271
- `job "${name}" needs retry.attempts >= 1, got ${String(definition.retry.attempts)}`,
272
- `set retry: { attempts: 1 } or higher on job("${name}") — 0 attempts means the job is never executed at all, not that it never retries`,
273
- );
269
+ // A COUNT, so whole and finite as well as at least 1: `>= 1` alone admitted `Infinity` and
270
+ // `1.5`, which the memory driver ran and Postgres refused at the first enqueue (`$7::int`).
271
+ // Zero is not "never retries" — that is `attempts: 1` — it is a job that never executes at all.
272
+ finiteCount(`job "${name}"`, 'retry.attempts', definition.retry.attempts, 1);
274
273
  // `concurrency: 0` is not "no cap" — it is a fleet slot table that grants nothing, and the job
275
274
  // is permanently unrunnable with no log line. Refused where it is written, plain or keyed.
276
275
  const concurrency = resolveConcurrency(name, definition.concurrency);
package/src/leases.ts CHANGED
@@ -57,7 +57,7 @@ export interface MemoryLeaseStore extends LeaseStore {
57
57
  * memory driver enforces `concurrency` with the same code path the pg driver does, and so the
58
58
  * "a second worker is refused" test is a real test rather than a pg-only one.
59
59
  */
60
- export function createMemoryLeaseStore(options: MemoryLeaseStoreOptions = {}): MemoryLeaseStore {
60
+ export function memoryLeaseStore(options: MemoryLeaseStoreOptions = {}): MemoryLeaseStore {
61
61
  const clock = options.clock ?? systemClock;
62
62
  type Slots = Map<number, { holder: string; expiresAt: number }>;
63
63
  const slots = new Map<string, Slots>();
package/src/limits.ts CHANGED
@@ -44,11 +44,21 @@ export interface LimitKey {
44
44
  readonly tenantId?: string;
45
45
  }
46
46
 
47
- /** Returned on success; `release()` is idempotent so a double-release cannot leak slots. */
47
+ /**
48
+ * Returned on success. Handed back exactly once, by whichever of the two is called first — the
49
+ * other, and any repeat, is a no-op, so a double hand-back cannot leak a slot or pop two stamps.
50
+ */
48
51
  export interface Lease {
49
52
  readonly queue: string;
50
53
  readonly tenantId: string;
54
+ /** The run ENDED: the slot is free, and the start stays counted against `ratePerTenant`. */
51
55
  release(): void;
56
+ /**
57
+ * The run never STARTED — shed over a fleet cap, refused by its key: the slot is free and the
58
+ * rate stamp `tryAcquire` wrote goes with it. `ratePerTenant` is starts per window, and a job
59
+ * handed back unrun every poll interval otherwise spent its tenant's whole window on sheds.
60
+ */
61
+ abandon(): void;
52
62
  }
53
63
 
54
64
  export type LimitReason = 'per-tenant' | 'per-queue' | 'global' | 'rate';
@@ -109,20 +119,20 @@ export function tenantKeyFrom(actor: { readonly orgId?: string } | undefined): s
109
119
  return actor?.orgId ?? NO_TENANT;
110
120
  }
111
121
 
112
- export function createLimiter(
122
+ export function concurrencyLimiter(
113
123
  config: LimitConfig,
114
124
  clock: Clock = systemClock,
115
125
  options: { readonly maxTenants?: number | undefined } = {},
116
126
  ): Limiter {
117
127
  const requested = options.maxTenants ?? DEFAULT_MAX_LIMIT_TENANTS;
118
- // Refused where it was written, for the reason `createPacer` refuses `rate: 0`: `Math.floor(NaN)`
128
+ // Refused where it was written, for the reason `backfillPacer` refuses `rate: 0`: `Math.floor(NaN)`
119
129
  // is `NaN` and `Math.floor(Infinity)` is `Infinity`, so BOTH cap comparisons below read false and
120
130
  // the option silently means "no cap at all" — the one setting this bound exists to make
121
131
  // unreachable. `Number(process.env.X)` is how a deployment writes the first of those.
122
132
  assert(
123
133
  Number.isFinite(requested),
124
134
  `job limiter maxTenants is ${String(requested)}, which caps nothing — the per-tenant maps would grow without a bound`,
125
- `pass a finite maxTenants to createLimiter(...), or omit it for the default ${String(DEFAULT_MAX_LIMIT_TENANTS)}`,
135
+ `pass a finite maxTenants to concurrencyLimiter(...), or omit it for the default ${String(DEFAULT_MAX_LIMIT_TENANTS)}`,
126
136
  );
127
137
  const maxTenants = Math.max(1, Math.floor(requested));
128
138
  const evictTo = Math.max(1, Math.floor(maxTenants * 0.9));
@@ -131,7 +141,7 @@ export function createLimiter(
131
141
  // `config.x !== undefined && count >= config.x`, so a `NaN` leaves the option PRESENT and the
132
142
  // comparison false forever. Measured: `global: Number(process.env.WORKER_GLOBAL_CONCURRENCY)`
133
143
  // with the variable unset granted 1000 of 1000 acquires while `snapshot().config` still reported
134
- // a configured ceiling. `ratePerTenant.windowMs` is on the list because it is half the same
144
+ // a configured ceiling. `ratePerTenant.windowMs` is screened too because it is half the same
135
145
  // ceiling: `stamp > at - NaN` is false for every stamp, so the window reads empty on every call.
136
146
  //
137
147
  // `min` is 0 on all five, deliberately: zero is a HARD STOP here and one this repo's own suite
@@ -143,10 +153,16 @@ export function createLimiter(
143
153
  ['perQueue', config.perQueue],
144
154
  ['global', config.global],
145
155
  ['ratePerTenant.limit', config.ratePerTenant?.limit],
146
- ['ratePerTenant.windowMs', config.ratePerTenant?.windowMs],
147
156
  ] as const) {
148
- if (value !== undefined) finiteCount('createLimiter', option, value);
157
+ if (value !== undefined) finiteCount('concurrencyLimiter', option, value);
149
158
  }
159
+ // Out of the loop because it is also READ, by `sweep`: the screened value is the one it reads.
160
+ // An absent rate window is 0, which `sweep` treats as "every stamp is spent".
161
+ const rateWindowMs = finiteCount(
162
+ 'concurrencyLimiter',
163
+ 'ratePerTenant.windowMs',
164
+ config.ratePerTenant?.windowMs ?? 0,
165
+ );
150
166
  const byQueue = new Map<string, number>();
151
167
  const byTenant = new Map<string, number>();
152
168
  // `{queue, tenantId}` is ONE key — `blockedBy` has always read it that way. Without this counter
@@ -186,9 +202,8 @@ export function createLimiter(
186
202
  */
187
203
  const sweep = (at: number): void => {
188
204
  lastSweepMs = at;
189
- const windowMs = config.ratePerTenant?.windowMs ?? 0;
190
205
  for (const [tenant, stamps] of starts) {
191
- if (stamps.length === 0 || (stamps.at(-1) ?? 0) <= at - windowMs) starts.delete(tenant);
206
+ if (stamps.length === 0 || (stamps.at(-1) ?? 0) <= at - rateWindowMs) starts.delete(tenant);
192
207
  }
193
208
  for (const [key, refusal] of refusals) {
194
209
  if (refusal.atMs <= at - REFUSAL_TTL_MS) refusals.delete(key);
@@ -261,16 +276,32 @@ export function createLimiter(
261
276
  maybeSweep(at);
262
277
 
263
278
  let released = false;
279
+ /** `false` when this lease was already handed back. */
280
+ const free = (): boolean => {
281
+ if (released) return false;
282
+ released = true;
283
+ global = Math.max(0, global - 1);
284
+ bump(byQueue, key.queue, -1);
285
+ bump(byTenant, tenant, -1);
286
+ bump(byQueueTenant, refusalKey, -1);
287
+ return true;
288
+ };
264
289
  return {
265
290
  queue: key.queue,
266
291
  tenantId: tenant,
267
292
  release() {
268
- if (released) return;
269
- released = true;
270
- global = Math.max(0, global - 1);
271
- bump(byQueue, key.queue, -1);
272
- bump(byTenant, tenant, -1);
273
- bump(byQueueTenant, refusalKey, -1);
293
+ free();
294
+ },
295
+ abandon() {
296
+ if (!free() || config.ratePerTenant === undefined) return;
297
+ // ONE stamp of this lease's own instant: two leases taken in the same millisecond wrote
298
+ // equal stamps, and either is this one. Absent when a sweep already dropped the window.
299
+ const stamps = starts.get(tenant);
300
+ const index = stamps?.lastIndexOf(at) ?? -1;
301
+ if (stamps === undefined || index === -1) return;
302
+ const rest = stamps.filter((_stamp, position) => position !== index);
303
+ if (rest.length === 0) starts.delete(tenant);
304
+ else starts.set(tenant, rest);
274
305
  },
275
306
  };
276
307
  },
@@ -23,7 +23,7 @@ export function resolveClaimLeaseMs(value: number | undefined): number {
23
23
  assert(
24
24
  Number.isInteger(value) && value > 0,
25
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',
26
+ 'pass a positive whole claimLeaseMs: 30_000 — postgresOutboxStore({ executor, txExecutor, claimLeaseMs: 30_000 }) — or omit the field for the 30s default',
27
27
  );
28
28
  return value;
29
29
  }
package/src/outbox-pg.ts CHANGED
@@ -4,16 +4,15 @@
4
4
  // memory store hangs rows off the `Tx` object and is correct only inside one process; this is
5
5
  // what a deployment installs.
6
6
  //
7
- // `txExecutor` is injected rather than resolved here for the reason `createPgDriver` takes a
7
+ // `txExecutor` is injected rather than resolved here for the reason `postgresJobDriver` takes a
8
8
  // `PgExecutor`: this package holds no `@ultimat3/db` dependency, and "which connection is this
9
9
  // `Tx` on" is a question only boot can answer. Boot has `currentTx()` — a `DbTx` IS a client on
10
10
  // the transaction's connection — so the wiring is one line there and no tier crossing here.
11
11
 
12
- import type { Clock } from '@ultimat3/core';
13
- import { uuid } from '@ultimat3/core';
12
+ import type { Clock, PgExecutor } from '@ultimat3/core';
13
+ import { uuidV7 } from '@ultimat3/core';
14
14
  import type { Tx } from '@ultimat3/entity';
15
15
  import { nowMs } from './clock';
16
- import type { PgExecutor } from './driver-pg';
17
16
  import {
18
17
  SQL_OUTBOX_CLAIM,
19
18
  SQL_OUTBOX_MARK_PUBLISHED,
@@ -40,7 +39,7 @@ interface OutboxRow {
40
39
  readonly claimed_by?: string | null;
41
40
  }
42
41
 
43
- export interface PgOutboxOptions {
42
+ export interface PostgresOutboxStoreOptions {
44
43
  /**
45
44
  * The pooled executor the RELAY uses: `claim`, `markPublished` and `pendingCount` all run after
46
45
  * the caller's transaction is gone, so they must not be bound to it.
@@ -88,7 +87,7 @@ function toRecord(row: OutboxRow): OutboxRecord {
88
87
  };
89
88
  }
90
89
 
91
- export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
90
+ export function postgresOutboxStore(options: PostgresOutboxStoreOptions): OutboxStore {
92
91
  // What each open transaction has staged, for `commit()`'s return value only. Never the source
93
92
  // of truth — that is the row, and the row's fate is the transaction's. A WeakMap so a `Tx` that
94
93
  // is neither committed nor rolled back (a process killed mid-request) leaves nothing behind.
@@ -97,7 +96,7 @@ export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
97
96
  // One id per store, minted here rather than per claim: `claimed_by` is read by an operator
98
97
  // asking which relay is sitting on a batch, and a value that changed every tick answers nobody.
99
98
  // Per-store is also the granularity the fence needs — two relays are two processes, two stores.
100
- const relayId = options.relayId ?? `relay-${uuid()}`;
99
+ const relayId = options.relayId ?? `relay-${uuidV7()}`;
101
100
  // Resolved once, at construction, so a lease this store could never honour fails where it was
102
101
  // written instead of inside a relay tick whose only trace is a log line nobody reads.
103
102
  const claimLeaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
@@ -52,23 +52,23 @@ export interface OutboxRelay {
52
52
  * At-least-once by construction: publish, THEN mark published. A crash between the two
53
53
  * re-publishes, which the idempotency key collapses — the opposite order would lose jobs.
54
54
  */
55
- export function createOutboxRelay(options: RelayOptions): OutboxRelay {
55
+ export function outboxRelay(options: RelayOptions): OutboxRelay {
56
56
  const batchSize = options.batchSize ?? 100;
57
57
  const intervalMs = options.intervalMs ?? 200;
58
58
  // Refused, never clamped — `worker-options.ts` carries the reason. `setInterval(fn, NaN)` reads
59
59
  // the delay as 0 and `claim(NaN)` slices `(0, NaN)`: a relay that spins and publishes nothing.
60
60
  assert(
61
61
  Number.isSafeInteger(batchSize) && batchSize >= 1,
62
- `createOutboxRelay batchSize is ${String(batchSize)} — a batch is a whole number of staged rows, at least one`,
63
- 'pass a finite batchSize to createOutboxRelay(...), or omit it for the default 100',
62
+ `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',
64
64
  );
65
65
  assert(
66
66
  Number.isFinite(intervalMs) && intervalMs >= 0,
67
- `createOutboxRelay intervalMs is ${String(intervalMs)}, which a timer reads as 0 — the relay would spin, not poll`,
68
- 'pass a finite intervalMs to createOutboxRelay(...), or omit it for the default 200',
67
+ `outboxRelay intervalMs is ${String(intervalMs)}, which a timer reads as 0 — the relay would spin, not poll`,
68
+ 'pass a finite intervalMs to outboxRelay(...), or omit it for the default 200',
69
69
  );
70
70
  const backoff = createIdleBackoff({
71
- subject: 'createOutboxRelay',
71
+ subject: 'outboxRelay',
72
72
  floorMs: intervalMs,
73
73
  ...(options.idlePollMaxMs === undefined ? {} : { ceilingMs: options.idlePollMaxMs }),
74
74
  });
package/src/outbox.ts CHANGED
@@ -14,9 +14,9 @@
14
14
  // `jobsFacade()` is what happens when they are not:
15
15
  //
16
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.
17
+ // 2. `setJobsFacade(outboxJobsFacade({ store, driver }, currentTx))` ran at boot, with a store
18
+ // from `postgresOutboxStore` and a REAL `currentTx` accessor.
19
+ // 3. `outboxRelay({ store, driver }).start()` is running somewhere.
20
20
  //
21
21
  // With none of them, `jobsFacade()` answers the fallback below, whose `currentTx` is
22
22
  // `() => undefined`: every enqueue publishes straight to the driver, outside the caller's
@@ -24,7 +24,13 @@
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 { currentSpanContext, traceparent, uuid } from '@ultimat3/core';
27
+ import {
28
+ currentSpanContext,
29
+ describeValue,
30
+ traceparent,
31
+ UltimateError,
32
+ uuidV7,
33
+ } from '@ultimat3/core';
28
34
  import type { Tx } from '@ultimat3/entity';
29
35
  import { nowMs } from './clock';
30
36
  import type { EnqueueResult, JobDriver } from './driver';
@@ -98,7 +104,7 @@ function byClaimOrder(a: OutboxRecord, b: OutboxRecord): number {
98
104
  return a.id < b.id ? -1 : 1;
99
105
  }
100
106
 
101
- export interface MemoryOutboxOptions {
107
+ export interface MemoryOutboxStoreOptions {
102
108
  readonly clock?: Clock;
103
109
  readonly claimLeaseMs?: number;
104
110
  }
@@ -117,7 +123,7 @@ export interface MemoryOutboxStore extends OutboxStore {
117
123
  * transaction" guarantee needs no cooperation from the DB layer and rollback is a delete.
118
124
  * The pg store swaps this for a real `x_outbox` table written by the same connection.
119
125
  */
120
- export function createMemoryOutboxStore(options: MemoryOutboxOptions = {}): MemoryOutboxStore {
126
+ export function memoryOutboxStore(options: MemoryOutboxStoreOptions = {}): MemoryOutboxStore {
121
127
  const staged = new WeakMap<object, OutboxRecord[]>();
122
128
  const committed = new Map<string, OutboxRecord>();
123
129
  /** Each claimed row's lease: when it was taken and by whom. Absent is `claimed_at is null`. */
@@ -238,6 +244,27 @@ function ambientTraceparent(): string | undefined {
238
244
  return traceparent(context);
239
245
  }
240
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
+
241
268
  export interface OutboxDeps {
242
269
  readonly store: OutboxStore;
243
270
  readonly driver: JobDriver;
@@ -247,18 +274,19 @@ export interface OutboxDeps {
247
274
  }
248
275
 
249
276
  /** Stage a job inside `tx`. Returns the row that the relay will publish after commit. */
250
- export function enqueueInTx<I>(
277
+ export async function enqueueInTx<I>(
251
278
  deps: OutboxDeps,
252
279
  tx: Tx,
253
280
  handle: JobHandle<I>,
254
281
  input: I,
255
282
  options: EnqueueOptions = {},
256
283
  ): Promise<OutboxRecord> {
284
+ const runId = namedRunId(handle.name, options.runId);
257
285
  const at = nowMs(deps.clock);
258
286
  const trace = options.traceparent ?? ambientTraceparent();
259
287
  const record: OutboxRecord = {
260
- id: uuid(),
261
- runId: options.runId ?? uuid(),
288
+ id: uuidV7(),
289
+ runId: runId ?? uuidV7(),
262
290
  job: handle.name,
263
291
  queue: options.queue ?? handle.queue ?? DEFAULT_QUEUE,
264
292
  input,
@@ -272,11 +300,10 @@ export function enqueueInTx<I>(
272
300
  ...(trace === undefined ? {} : { traceparent: trace }),
273
301
  ...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
274
302
  };
275
- return deps.store.stage(tx, record).then(() => {
276
- // Wakes a relay in THIS process that has backed off; a relay elsewhere polls on its own.
277
- signalStaged();
278
- return record;
279
- });
303
+ await deps.store.stage(tx, record);
304
+ // Wakes a relay in THIS process that has backed off; a relay elsewhere polls on its own.
305
+ signalStaged();
306
+ return record;
280
307
  }
281
308
 
282
309
  export interface JobsFacade {
@@ -287,7 +314,7 @@ export interface JobsFacade {
287
314
  enqueue<I>(handle: JobHandle<I>, input: I, options?: EnqueueOptions): Promise<EnqueueResult>;
288
315
  }
289
316
 
290
- export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefined): JobsFacade {
317
+ export function outboxJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefined): JobsFacade {
291
318
  return {
292
319
  async enqueue<I>(
293
320
  handle: JobHandle<I>,
@@ -304,6 +331,7 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
304
331
  if (deps.mode === 'required' && options.outbox !== false) {
305
332
  throw new OutboxNoTxError({ job: handle.name });
306
333
  }
334
+ const runId = namedRunId(handle.name, options.runId);
307
335
  const trace = options.traceparent ?? ambientTraceparent();
308
336
  const result = await deps.driver.enqueue({
309
337
  name: handle.name,
@@ -312,7 +340,7 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
312
340
  idempotencyKey: handle.idempotencyKeyFor(input),
313
341
  maxAttempts: handle.retry.attempts,
314
342
  runAt: options.runAt ?? nowMs(deps.clock),
315
- ...(options.runId === undefined ? {} : { runId: options.runId }),
343
+ ...(runId === undefined ? {} : { runId }),
316
344
  ...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
317
345
  ...(trace === undefined ? {} : { traceparent: trace }),
318
346
  ...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
@@ -351,7 +379,7 @@ export function setJobsFacade(facade: JobsFacade | null): void {
351
379
  */
352
380
  export function jobsFacade(): JobsFacade {
353
381
  if (ambient !== undefined) return ambient;
354
- fallback ??= createJobsFacade(
382
+ fallback ??= outboxJobsFacade(
355
383
  {
356
384
  // A getter, not a snapshot: `setJobDriver()` after the first enqueue is honoured, and a
357
385
  // missing driver is an error at the call rather than at import time.
@@ -361,14 +389,14 @@ export function jobsFacade(): JobsFacade {
361
389
  throw new DriverUnavailableError({
362
390
  driver: 'none',
363
391
  cause: 'no queue driver is installed in this process',
364
- fix: 'call setJobDriver(createMemoryDriver()) before enqueuing, or setJobDriver(createPgDriver()) for a real queue',
392
+ fix: 'call setJobDriver(memoryJobDriver()) before enqueuing, or setJobDriver(postgresJobDriver()) for a real queue',
365
393
  });
366
394
  }
367
395
  return installed;
368
396
  },
369
397
  // Unreachable while `currentTx` is `() => undefined`; present because `OutboxDeps`
370
398
  // requires a store, and a real one is cheaper than an assertion that cannot fire.
371
- store: createMemoryOutboxStore(),
399
+ store: memoryOutboxStore(),
372
400
  },
373
401
  () => undefined,
374
402
  );
package/src/purge.ts CHANGED
@@ -33,7 +33,7 @@ export interface PurgeTarget {
33
33
  /**
34
34
  * What this sweep is called in its durable step, its log line and its report. A table name is
35
35
  * the natural spelling (`x_rate_limit`); a target that clears a SET of tables names their common
36
- * prefix (`x_auth`, for `x_auth_failures` and `x_auth_lockouts`). Unique within one definition —
36
+ * prefix (`x_auth`, for `x_auth_lockouts`). Unique within one definition —
37
37
  * the name is the step key, and two steps under one name is `X_STEP_DUPLICATE` mid-run.
38
38
  */
39
39
  readonly name: string;
package/src/queue-wake.ts CHANGED
@@ -5,9 +5,8 @@
5
5
  // the poll keeps running underneath, and this file's other job is to say whether the wake is
6
6
  // PROVEN: the loops raise their idle ceiling only while it is.
7
7
 
8
- import type { Random } from '@ultimat3/core';
8
+ import type { PgExecutor, Random } from '@ultimat3/core';
9
9
  import { backoffDelay, finiteOption, logger, renderThrowable } from '@ultimat3/core';
10
- import type { PgExecutor } from './driver-pg';
11
10
  import { JOBS_WAKE_CHANNEL, OUTBOX_WAKE_CHANNEL, SQL_WAKE } from './driver-pg-wake-sql';
12
11
  import { setWakeLive, signalEnqueued, signalStaged } from './enqueue-signal';
13
12
 
@@ -20,7 +20,7 @@ export interface RenewalTimer {
20
20
 
21
21
  /**
22
22
  * Calls `tick` every `intervalMs` until the canceller it returns runs. The seam a lease, a fleet
23
- * slot and a registry row renew through: `createWorker({ schedule })` hands one in, and the
23
+ * slot and a registry row renew through: `jobWorker({ schedule })` hands one in, and the
24
24
  * `runJobs` fixture's is driven by the frozen clock, so a test renews when time passes and never
25
25
  * on the wall clock.
26
26
  */
@@ -3,10 +3,16 @@
3
3
  // The backoff arithmetic stays in ./retry — nothing here recomputes a delay `nextRetry` owns.
4
4
 
5
5
  import type { ErrorRetry } from '@ultimat3/core';
6
- import { classifyThrown, isUltimateError, renderThrowable, statedDelayMs } from '@ultimat3/core';
6
+ import {
7
+ classifyThrown,
8
+ isUltimateError,
9
+ jitterStatedDelay,
10
+ renderThrowable,
11
+ statedDelayMs,
12
+ } from '@ultimat3/core';
7
13
  import { finiteDurationMs } from './clock';
8
14
  import type { Random, RetryDecision, RetryPolicy } from './retry';
9
- import { DEFAULT_RETRY, nextRetry } from './retry';
15
+ import { backoffDelayMs, DEFAULT_RETRY, nextRetry } from './retry';
10
16
 
11
17
  /** Why this attempt was the last one. Absent while the job is still being retried. */
12
18
  export type JobStopReason = 'terminal' | 'attempts-exhausted';
@@ -17,16 +23,10 @@ export interface JobRetryDecision extends RetryDecision {
17
23
  readonly classification: ErrorRetry | undefined;
18
24
  }
19
25
 
20
- /**
21
- * Core's, re-exported rather than copied — they are the readers of core's classification table and
22
- * every executor in the framework has to answer them the same way. Both were declared here first
23
- * and moved down a tier VERBATIM, the subtle rule included: an UNCLASSIFIED code carrying an
24
- * instance `retry: 'terminal'` reads as unclassified, because a per-instance `terminal` is
25
- * indistinguishable from the fail-closed default and honouring it would dead-letter the first
26
- * attempt of every job in every app whose codes nobody has classified. `retry-classification.test.ts`
27
- * pins that they are the same FUNCTION, not merely two functions that agree today.
28
- */
29
- export { classifyThrown, statedDelayMs };
26
+ // `classifyThrown` and `statedDelayMs` are core's, imported — the readers of core's classification
27
+ // table, which every executor answers the same way. An UNCLASSIFIED code carrying an instance
28
+ // `retry: 'terminal'` reads as unclassified (a per-instance `terminal` is indistinguishable from
29
+ // the fail-closed default); `retry-classification.test.ts` holds that rule against core's function.
30
30
 
31
31
  /**
32
32
  * Retry, dead-letter, and when. `terminal` stops here on the attempt that failed — the same code
@@ -65,10 +65,47 @@ export function nextRetryForError(
65
65
 
66
66
  const stated = statedDelayMs(error);
67
67
  if (stated === undefined) return { ...decision, stoppedBy: undefined, classification };
68
- // Clamped by the policy's own ceiling, which is what `maxDelay` is for: a responder naming a
69
- // day is still a responder this deployment has not agreed to wait a day for.
68
+ // The floor is clamped by the policy's own ceiling, which is what `maxDelay` is for: a responder
69
+ // naming a day is still a responder this deployment has not agreed to wait a day for. The spread
70
+ // on top is core's `jitterStatedDelay` — the one rule for a named delay — because an HTTP-date
71
+ // hands every delivery to one receiver the SAME instant, and waking them together is the herd.
72
+ const cap = finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay');
73
+ // `jitter: false` is the policy's own decision and is honoured: the bare floor.
74
+ const floor = Math.min(stated, cap);
75
+ const jittered = (policy.jitter ?? DEFAULT_RETRY.jitter) === true;
76
+ const delayMs = jittered ? jitterStatedDelay(floor, cap, random) : floor;
77
+ return { ...decision, delayMs, stoppedBy: undefined, classification };
78
+ }
79
+
80
+ /**
81
+ * `@ultimat3/http`'s `rateLimited()` — the refusal a declared `rateLimit:` answers on every
82
+ * surface, a job's `.job()`/`llm()` run included. Named, not classified: `retry-after` also covers
83
+ * `X_OVERLOADED`, a shed that should still spend the policy's attempts.
84
+ */
85
+ const RATE_LIMITED = 'X_RATE_LIMITED';
86
+
87
+ /**
88
+ * How long to wait out a rate-limit refusal, or `undefined` when `error` is not one. A refusal is
89
+ * "not yet", never "failed": the bucket refills, so the run is rescheduled with its attempt
90
+ * UNCOUNTED — counted, a backlog of rate-limited jobs spent every attempt waiting and was
91
+ * dead-lettered for it.
92
+ *
93
+ * The stated `Retry-After` is the FLOOR, never clamped: a run woken before the refill is refused
94
+ * again, so clamping a 3600 s refusal to `maxDelay` re-ran the backlog every minute for an hour.
95
+ * With none stated, the policy's own backoff. On top, a spread in `[0, min(wait / 2, maxDelay))`:
96
+ * a backlog refused together and woken at one instant is refused together again, a claim storm
97
+ * that repeats forever. `maxDelay` bounds only that spread.
98
+ */
99
+ export function rateLimitDeferralMs(
100
+ policy: RetryPolicy,
101
+ attempt: number,
102
+ error: unknown,
103
+ random: Random = Math.random,
104
+ ): number | undefined {
105
+ if (!isUltimateError(error) || error.code !== RATE_LIMITED) return undefined;
70
106
  const cap = finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay');
71
- return { ...decision, delayMs: Math.min(stated, cap), stoppedBy: undefined, classification };
107
+ const wait = statedDelayMs(error) ?? Math.min(backoffDelayMs(policy, attempt), cap);
108
+ return jitterStatedDelay(wait, cap, random);
72
109
  }
73
110
 
74
111
  /**
@@ -15,8 +15,8 @@ export interface LeaderElection {
15
15
  readonly renewEveryMs: number;
16
16
  }
17
17
 
18
- /** Single-node default: always the leader. Multi-node uses `createPgLeaseLeader()` — never
19
- * `createPgLeader()`, whose advisory lock is owned by a pooled session this process cannot name. */
18
+ /** Single-node default: always the leader. Multi-node uses `postgresLeaseLeader()` — never
19
+ * `postgresLeader()`, whose advisory lock is owned by a pooled session this process cannot name. */
20
20
  export function soleLeader(): LeaderElection {
21
21
  return {
22
22
  acquire: () => Promise.resolve(true),