@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.
- package/CLAUDE.md +72 -75
- package/README.md +83 -38
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +25 -3
- package/src/backfill-pass.ts +22 -5
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/claim-exhausted.ts +76 -0
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +34 -12
- package/src/driver-memory.ts +80 -38
- package/src/driver-pg-jobs-sql.ts +27 -19
- package/src/driver-pg-operator-sql.ts +32 -12
- package/src/driver-pg-operator.ts +19 -3
- package/src/driver-pg-rows.ts +3 -3
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +99 -30
- package/src/driver-pg.ts +103 -98
- package/src/driver.ts +46 -16
- package/src/errors-concurrency.ts +1 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +9 -17
- package/src/events-pg.ts +11 -21
- package/src/events.ts +26 -15
- package/src/execute.ts +47 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +45 -99
- package/src/inspect-operator.ts +4 -4
- package/src/inspect.ts +5 -4
- package/src/introspection.ts +23 -11
- package/src/job.ts +6 -7
- package/src/leases.ts +1 -1
- package/src/limits.ts +46 -15
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +6 -7
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +47 -19
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +19 -16
- package/src/scheduler-state.ts +10 -3
- package/src/scheduler.ts +14 -15
- package/src/steps-memory.ts +2 -2
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +90 -88
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +64 -118
- package/src/worker-admit.ts +14 -6
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +82 -45
- package/src/worker-held.ts +100 -0
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-options.ts +19 -15
- package/src/worker-registry.ts +16 -1
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +124 -73
- package/src/driver-nats.ts +0 -86
- package/src/driver-redis.ts +0 -89
package/src/introspection.ts
CHANGED
|
@@ -68,7 +68,12 @@ export interface JobFilter {
|
|
|
68
68
|
readonly tenantId?: string;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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`,
|
|
187
|
-
*
|
|
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
|
|
195
|
-
*
|
|
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
|
-
* `
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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('
|
|
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 -
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
},
|
package/src/outbox-lease.ts
CHANGED
|
@@ -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 —
|
|
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 `
|
|
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 {
|
|
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
|
|
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
|
|
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-${
|
|
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);
|
package/src/outbox-relay.ts
CHANGED
|
@@ -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
|
|
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
|
-
`
|
|
63
|
-
'pass a finite batchSize to
|
|
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
|
-
`
|
|
68
|
-
'pass a finite intervalMs to
|
|
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: '
|
|
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(
|
|
18
|
-
// from `
|
|
19
|
-
// 3. `
|
|
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 {
|
|
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
|
|
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
|
|
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:
|
|
261
|
-
runId:
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
...(
|
|
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 ??=
|
|
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(
|
|
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:
|
|
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 `
|
|
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
|
|
package/src/renewal-timer.ts
CHANGED
|
@@ -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: `
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
107
|
+
const wait = statedDelayMs(error) ?? Math.min(backoffDelayMs(policy, attempt), cap);
|
|
108
|
+
return jitterStatedDelay(wait, cap, random);
|
|
72
109
|
}
|
|
73
110
|
|
|
74
111
|
/**
|
package/src/scheduler-leader.ts
CHANGED
|
@@ -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 `
|
|
19
|
-
* `
|
|
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),
|