@ultimat3/jobs 2.0.0 → 4.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 +146 -15
- package/README.md +80 -4
- package/package.json +5 -5
- package/src/backfill-errors.ts +137 -0
- package/src/backfill-gate.ts +5 -5
- package/src/backfill-pass.ts +1 -1
- package/src/describe.ts +17 -1
- package/src/driver-memory.ts +34 -8
- package/src/driver-pg-ddl.ts +31 -6
- package/src/driver-pg-rows.ts +34 -6
- package/src/driver-pg-sql.ts +79 -9
- package/src/driver-pg.ts +15 -5
- package/src/driver.ts +36 -12
- package/src/errors.ts +81 -130
- package/src/execute.ts +33 -9
- package/src/heartbeat.ts +15 -13
- package/src/index.ts +23 -8
- package/src/job.ts +55 -1
- package/src/metrics.ts +1 -1
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +58 -7
- package/src/outbox.ts +91 -8
- package/src/register.ts +25 -1
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/retry.ts +4 -3
- package/src/steps.ts +14 -1
- package/src/task.ts +29 -2
- package/src/worker-fleet-slots.ts +16 -11
- package/src/worker-run.ts +3 -0
- package/src/worker.ts +34 -9
package/src/outbox-pg.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// the transaction's connection — so the wiring is one line there and no tier crossing here.
|
|
11
11
|
|
|
12
12
|
import type { Clock } from '@ultimat3/core';
|
|
13
|
+
import { uuid } from '@ultimat3/core';
|
|
13
14
|
import type { Tx } from '@ultimat3/entity';
|
|
14
15
|
import { nowMs } from './clock';
|
|
15
16
|
import type { PgExecutor } from './driver-pg';
|
|
@@ -17,9 +18,11 @@ import {
|
|
|
17
18
|
SQL_OUTBOX_CLAIM,
|
|
18
19
|
SQL_OUTBOX_MARK_PUBLISHED,
|
|
19
20
|
SQL_OUTBOX_PENDING_COUNT,
|
|
21
|
+
SQL_OUTBOX_RELEASE,
|
|
20
22
|
SQL_OUTBOX_STAGE,
|
|
21
23
|
} from './driver-pg-sql';
|
|
22
24
|
import type { OutboxRecord, OutboxStore } from './outbox';
|
|
25
|
+
import { resolveClaimLeaseMs } from './outbox-lease';
|
|
23
26
|
|
|
24
27
|
interface OutboxRow {
|
|
25
28
|
readonly id: string;
|
|
@@ -33,6 +36,7 @@ interface OutboxRow {
|
|
|
33
36
|
readonly tenant_id: string | null;
|
|
34
37
|
readonly traceparent: string | null;
|
|
35
38
|
readonly enqueued_by: string | null;
|
|
39
|
+
readonly claimed_by?: string | null;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface PgOutboxOptions {
|
|
@@ -47,6 +51,22 @@ export interface PgOutboxOptions {
|
|
|
47
51
|
*/
|
|
48
52
|
readonly txExecutor: (tx: Tx) => PgExecutor;
|
|
49
53
|
readonly clock?: Clock;
|
|
54
|
+
/**
|
|
55
|
+
* How long a claimed row stays this relay's before any relay may take it again. It bounds one
|
|
56
|
+
* thing only: how long the rows of a relay that DIED mid-batch sit unpublished. A pass that is
|
|
57
|
+
* merely slow keeps its rows because it published them; a pass that failed hands them back
|
|
58
|
+
* through `release`.
|
|
59
|
+
*/
|
|
60
|
+
readonly claimLeaseMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Written to `claimed_by`, and read back as the FENCE on `release` and `markPublished` — so it
|
|
63
|
+
* must be UNIQUE PER PROCESS. Two replicas passing one literal are one claimant to Postgres, and
|
|
64
|
+
* each can then release or retire the other's live batch. Omit it: the default is
|
|
65
|
+
* `relay-<uuid>`, minted once per store, which is unique by construction. Diagnostics second —
|
|
66
|
+
* it is what an operator reads to see which relay is sitting on a batch, and a value that
|
|
67
|
+
* changed every tick would answer nobody.
|
|
68
|
+
*/
|
|
69
|
+
readonly relayId?: string;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
function toRecord(row: OutboxRow): OutboxRecord {
|
|
@@ -62,6 +82,7 @@ function toRecord(row: OutboxRow): OutboxRecord {
|
|
|
62
82
|
...(row.tenant_id === null ? {} : { tenantId: row.tenant_id }),
|
|
63
83
|
...(row.traceparent === null ? {} : { traceparent: row.traceparent }),
|
|
64
84
|
...(row.enqueued_by === null ? {} : { enqueuedBy: row.enqueued_by }),
|
|
85
|
+
...(typeof row.claimed_by === 'string' ? { claimedBy: row.claimed_by } : {}),
|
|
65
86
|
};
|
|
66
87
|
}
|
|
67
88
|
|
|
@@ -71,6 +92,13 @@ export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
|
|
|
71
92
|
// is neither committed nor rolled back (a process killed mid-request) leaves nothing behind.
|
|
72
93
|
const staged = new WeakMap<object, OutboxRecord[]>();
|
|
73
94
|
const key = (tx: Tx): object => tx as unknown as object;
|
|
95
|
+
// One id per store, minted here rather than per claim: `claimed_by` is read by an operator
|
|
96
|
+
// asking which relay is sitting on a batch, and a value that changed every tick answers nobody.
|
|
97
|
+
// Per-store is also the granularity the fence needs — two relays are two processes, two stores.
|
|
98
|
+
const relayId = options.relayId ?? `relay-${uuid()}`;
|
|
99
|
+
// Resolved once, at construction, so a lease this store could never honour fails where it was
|
|
100
|
+
// written instead of inside a relay tick whose only trace is a log line nobody reads.
|
|
101
|
+
const claimLeaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
|
|
74
102
|
|
|
75
103
|
return {
|
|
76
104
|
async stage(tx, record) {
|
|
@@ -112,18 +140,41 @@ export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
|
|
|
112
140
|
},
|
|
113
141
|
|
|
114
142
|
/**
|
|
115
|
-
* `for update skip locked`
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
143
|
+
* A CLAIM, not a read. `for update skip locked` in a bare select held its locks only for that
|
|
144
|
+
* statement — which under autocommit is over before this method resolves — so two relays
|
|
145
|
+
* polling 200ms apart got the identical batch and both published it. The idempotency key
|
|
146
|
+
* collapses that only while the first job is still live, so the repeat that lands after it
|
|
147
|
+
* finished runs the handler a second time. `SQL_OUTBOX_CLAIM` stamps `claimed_at` in the same
|
|
148
|
+
* statement that locks the row; `skip locked` still keeps two relays from serialising.
|
|
119
149
|
*/
|
|
120
150
|
async claim(limit) {
|
|
121
|
-
const rows = await options.executor.query<OutboxRow>(SQL_OUTBOX_CLAIM, [
|
|
151
|
+
const rows = await options.executor.query<OutboxRow>(SQL_OUTBOX_CLAIM, [
|
|
152
|
+
limit,
|
|
153
|
+
claimLeaseMs,
|
|
154
|
+
relayId,
|
|
155
|
+
]);
|
|
122
156
|
return rows.map(toRecord);
|
|
123
157
|
},
|
|
124
158
|
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Fenced on the CLAIMANT, not only on the ids. A relay that stalled past its lease wakes into
|
|
161
|
+
* a world where its batch belongs to another relay, and an unfenced release frees rows that
|
|
162
|
+
* relay is mid-publish on — a third relay claims them and publishes them again. `relayId` is
|
|
163
|
+
* the fallback because it is what this store stamped: a caller with no token is this store's
|
|
164
|
+
* own relay, and one holding somebody else's token could not have got it from here.
|
|
165
|
+
*/
|
|
166
|
+
async release(ids, claimant) {
|
|
167
|
+
if (ids.length === 0) return;
|
|
168
|
+
await options.executor.query(SQL_OUTBOX_RELEASE, [ids, claimant ?? relayId]);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/** Same fence, and worse to miss: marking a row published is losing the job behind it. */
|
|
172
|
+
async markPublished(id, at, claimant) {
|
|
173
|
+
await options.executor.query(SQL_OUTBOX_MARK_PUBLISHED, [
|
|
174
|
+
id,
|
|
175
|
+
at || nowMs(options.clock),
|
|
176
|
+
claimant ?? relayId,
|
|
177
|
+
]);
|
|
127
178
|
},
|
|
128
179
|
|
|
129
180
|
async pendingCount() {
|
package/src/outbox.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type { EnqueueResult, JobDriver } from './driver';
|
|
|
31
31
|
import { DEFAULT_QUEUE, jobDriver } from './driver';
|
|
32
32
|
import { DriverUnavailableError, OutboxNoTxError } from './errors';
|
|
33
33
|
import type { JobHandle } from './job';
|
|
34
|
+
import { resolveClaimLeaseMs } from './outbox-lease';
|
|
34
35
|
|
|
35
36
|
export interface OutboxRecord {
|
|
36
37
|
readonly id: string;
|
|
@@ -46,6 +47,11 @@ export interface OutboxRecord {
|
|
|
46
47
|
readonly traceparent?: string;
|
|
47
48
|
readonly enqueuedBy?: string;
|
|
48
49
|
readonly publishedAt?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Stamped by `claim()`, absent on a staged row. Hand it back to `release`/`markPublished`: it is
|
|
52
|
+
* the FENCE, so a claimant whose lease lapsed cannot touch the rows a newer one is publishing.
|
|
53
|
+
*/
|
|
54
|
+
readonly claimedBy?: string;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export interface OutboxStore {
|
|
@@ -55,12 +61,44 @@ export interface OutboxStore {
|
|
|
55
61
|
commit(tx: Tx): Promise<readonly OutboxRecord[]>;
|
|
56
62
|
/** Called by the tx runner after ROLLBACK. Staged rows vanish with the transaction. */
|
|
57
63
|
rollback(tx: Tx): Promise<void>;
|
|
58
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* CLAIM unpublished, committed rows — the relay's work queue, and a lease rather than a read.
|
|
66
|
+
* A store that hands the same rows to two relays hands the same job to two workers, and the
|
|
67
|
+
* idempotency key only collapses that while the first job is still live.
|
|
68
|
+
*/
|
|
59
69
|
claim(limit: number): Promise<readonly OutboxRecord[]>;
|
|
60
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Hand a claim back before its lease runs out, for the batch a failed publish stopped. OPTIONAL
|
|
72
|
+
* so a store written before the claim became a lease still compiles: without it those rows wait
|
|
73
|
+
* out the whole lease, which is slower, never wrong.
|
|
74
|
+
*
|
|
75
|
+
* `claimant` is the `claimedBy` the claim stamped. Passing it is what makes a lapsed relay's
|
|
76
|
+
* late release a no-op instead of an unclaim of somebody else's live batch.
|
|
77
|
+
*/
|
|
78
|
+
release?(ids: readonly string[], claimant?: string): Promise<void>;
|
|
79
|
+
/** `claimant` fences the same way, and here it is worse to miss: this retires the row. */
|
|
80
|
+
markPublished(id: string, at: number, claimant?: string): Promise<void>;
|
|
61
81
|
pendingCount(): Promise<number>;
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The claim's sort key, and it is TOTAL: `id` after `stagedAt`, exactly what `SQL_OUTBOX_CLAIM`
|
|
86
|
+
* orders by. Every row staged in one transaction shares a `stagedAt`, so the key ties for the
|
|
87
|
+
* batch that most depends on order — and a tie leaves both which rows a limit takes and the order
|
|
88
|
+
* they publish in to whatever the store iterated first. Code units, never `localeCompare`, for
|
|
89
|
+
* the reason `registeredJobs()` sorts that way.
|
|
90
|
+
*/
|
|
91
|
+
function byClaimOrder(a: OutboxRecord, b: OutboxRecord): number {
|
|
92
|
+
if (a.stagedAt !== b.stagedAt) return a.stagedAt - b.stagedAt;
|
|
93
|
+
if (a.id === b.id) return 0;
|
|
94
|
+
return a.id < b.id ? -1 : 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface MemoryOutboxOptions {
|
|
98
|
+
readonly clock?: Clock;
|
|
99
|
+
readonly claimLeaseMs?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
64
102
|
export interface MemoryOutboxStore extends OutboxStore {
|
|
65
103
|
/**
|
|
66
104
|
* Committed rows this process is still holding. The relay's backlog and nothing else — a
|
|
@@ -75,11 +113,29 @@ export interface MemoryOutboxStore extends OutboxStore {
|
|
|
75
113
|
* transaction" guarantee needs no cooperation from the DB layer and rollback is a delete.
|
|
76
114
|
* The pg store swaps this for a real `x_outbox` table written by the same connection.
|
|
77
115
|
*/
|
|
78
|
-
export function createMemoryOutboxStore(): MemoryOutboxStore {
|
|
116
|
+
export function createMemoryOutboxStore(options: MemoryOutboxOptions = {}): MemoryOutboxStore {
|
|
79
117
|
const staged = new WeakMap<object, OutboxRecord[]>();
|
|
80
118
|
const committed = new Map<string, OutboxRecord>();
|
|
119
|
+
/** Each claimed row's lease: when it was taken and by whom. Absent is `claimed_at is null`. */
|
|
120
|
+
const claims = new Map<string, { at: number; by: string }>();
|
|
121
|
+
const leaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
|
|
122
|
+
// A token per CLAIM, where the pg store stamps one per RELAY. Two relays there are two stores
|
|
123
|
+
// with two ids; here they are two `claim()` calls on one store, so the claim is the only
|
|
124
|
+
// granularity at which this store can answer "is this mutation from the current holder".
|
|
125
|
+
let claimSeq = 0;
|
|
81
126
|
|
|
82
127
|
const key = (tx: Tx): object => tx as unknown as object;
|
|
128
|
+
const free = (id: string, at: number): boolean => {
|
|
129
|
+
const claim = claims.get(id);
|
|
130
|
+
return claim === undefined || at - claim.at >= leaseMs;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* A mutation from a claimant that no longer holds the row is a NO-OP. `undefined` is the caller
|
|
134
|
+
* that holds no token at all — a store-level caller, or one written before the fence — and is
|
|
135
|
+
* left unfenced rather than silently dropped, the way `release` itself is optional.
|
|
136
|
+
*/
|
|
137
|
+
const owns = (id: string, claimant: string | undefined): boolean =>
|
|
138
|
+
claimant === undefined || claims.get(id)?.by === claimant;
|
|
83
139
|
|
|
84
140
|
return {
|
|
85
141
|
stage(tx, record) {
|
|
@@ -98,19 +154,36 @@ export function createMemoryOutboxStore(): MemoryOutboxStore {
|
|
|
98
154
|
staged.delete(key(tx));
|
|
99
155
|
return Promise.resolve();
|
|
100
156
|
},
|
|
157
|
+
/**
|
|
158
|
+
* The same question `SQL_OUTBOX_CLAIM` answers, and it has to stay the same one: a row this
|
|
159
|
+
* store hands back is CLAIMED for `leaseMs`, so a second relay polling the same store gets
|
|
160
|
+
* nothing, and a claim whose holder died is reclaimable once the window passes.
|
|
161
|
+
*/
|
|
101
162
|
claim(limit) {
|
|
163
|
+
const at = nowMs(options.clock);
|
|
164
|
+
claimSeq += 1;
|
|
165
|
+
const by = `claim-${claimSeq}`;
|
|
102
166
|
const ready = [...committed.values()]
|
|
103
|
-
.filter((record) => record.publishedAt === undefined)
|
|
104
|
-
.sort(
|
|
167
|
+
.filter((record) => record.publishedAt === undefined && free(record.id, at))
|
|
168
|
+
.sort(byClaimOrder)
|
|
105
169
|
.slice(0, limit);
|
|
106
|
-
|
|
170
|
+
for (const record of ready) claims.set(record.id, { at, by });
|
|
171
|
+
return Promise.resolve(ready.map((record) => ({ ...record, claimedBy: by })));
|
|
172
|
+
},
|
|
173
|
+
release(ids, claimant) {
|
|
174
|
+
for (const id of ids) {
|
|
175
|
+
if (owns(id, claimant)) claims.delete(id);
|
|
176
|
+
}
|
|
177
|
+
return Promise.resolve();
|
|
107
178
|
},
|
|
108
|
-
markPublished(id, _at) {
|
|
179
|
+
markPublished(id, _at, claimant) {
|
|
180
|
+
if (!owns(id, claimant)) return Promise.resolve();
|
|
109
181
|
// Deleted, not stamped. A published row is out of the relay's reach either way, and the
|
|
110
182
|
// pg store's `published_at` column is a retained audit trail this map is not: rewriting
|
|
111
183
|
// it in place held every payload ever enqueued — arbitrary job input — for the life of
|
|
112
184
|
// the process, and made `claim()` and `pendingCount()` walk all of them every 200ms.
|
|
113
185
|
committed.delete(id);
|
|
186
|
+
claims.delete(id);
|
|
114
187
|
return Promise.resolve();
|
|
115
188
|
},
|
|
116
189
|
pendingCount() {
|
|
@@ -331,7 +404,10 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
331
404
|
...(record.traceparent === undefined ? {} : { traceparent: record.traceparent }),
|
|
332
405
|
...(record.enqueuedBy === undefined ? {} : { enqueuedBy: record.enqueuedBy }),
|
|
333
406
|
});
|
|
334
|
-
|
|
407
|
+
// The claim's own token goes back with the mark. Without it a relay whose lease lapsed
|
|
408
|
+
// mid-stall retires a row the relay that reclaimed it has not published yet — the row is
|
|
409
|
+
// gone and nothing publishes it.
|
|
410
|
+
await options.store.markPublished(record.id, nowMs(options.clock), record.claimedBy);
|
|
335
411
|
published += 1;
|
|
336
412
|
} catch (error) {
|
|
337
413
|
// STOP the batch. `claim()` returns rows in `staged_at` order and the loop used to log
|
|
@@ -347,6 +423,13 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
347
423
|
remaining: batch.length - published,
|
|
348
424
|
error: error instanceof Error ? error.message : String(error),
|
|
349
425
|
});
|
|
426
|
+
// Hand the rest of the batch back rather than sit on a claim nobody is publishing. The
|
|
427
|
+
// claim is a lease now, so without this a single pool timeout parks every committed row
|
|
428
|
+
// behind it for the whole lease window instead of for one poll interval.
|
|
429
|
+
await options.store.release?.(
|
|
430
|
+
batch.slice(published).map((row) => row.id),
|
|
431
|
+
record.claimedBy,
|
|
432
|
+
);
|
|
350
433
|
break;
|
|
351
434
|
}
|
|
352
435
|
}
|
package/src/register.ts
CHANGED
|
@@ -5,9 +5,24 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { type RegisteredPrimitive, registerPrimitiveRegistrar } from '@ultimat3/core';
|
|
8
|
+
import { ActionJobUnbridgedError } from './errors';
|
|
8
9
|
import { isJobHandle, registerJob } from './job';
|
|
9
10
|
import { isTaskHandle, registerTask } from './task';
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* `@ultimat3/action`'s job PROJECTION, recognised structurally because that package is this tier
|
|
14
|
+
* and may never be imported here. `kind: 'action-job'` is a literal chosen to be distinguishable
|
|
15
|
+
* from `'job'` rather than a near-miss (`packages/action/src/job-handle.ts` says so), which is
|
|
16
|
+
* precisely what makes this check possible without an import.
|
|
17
|
+
*/
|
|
18
|
+
const isActionProjection = (
|
|
19
|
+
value: unknown,
|
|
20
|
+
): value is { readonly kind: 'action-job'; readonly name: string } =>
|
|
21
|
+
typeof value === 'object' &&
|
|
22
|
+
value !== null &&
|
|
23
|
+
'kind' in value &&
|
|
24
|
+
(value as { readonly kind: unknown }).kind === 'action-job';
|
|
25
|
+
|
|
11
26
|
/** `registerJobs(await import('./jobs'))` — export names become job names. */
|
|
12
27
|
export function registerJobs(
|
|
13
28
|
module: Readonly<Record<string, unknown>>,
|
|
@@ -15,7 +30,16 @@ export function registerJobs(
|
|
|
15
30
|
const registered: RegisteredPrimitive[] = [];
|
|
16
31
|
for (const name of Object.keys(module).sort()) {
|
|
17
32
|
const value = module[name];
|
|
18
|
-
if (isJobHandle(value))
|
|
33
|
+
if (isJobHandle(value)) {
|
|
34
|
+
registered.push(registerJob(name, value));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Everything else is skipped in silence — a module namespace is full of constants, types and
|
|
38
|
+
// helpers exported beside the jobs. Everything else EXCEPT this one, which is unambiguously
|
|
39
|
+
// someone trying to queue an action and getting nothing at all.
|
|
40
|
+
if (isActionProjection(value)) {
|
|
41
|
+
throw new ActionJobUnbridgedError({ export: name, job: value.name });
|
|
42
|
+
}
|
|
19
43
|
}
|
|
20
44
|
return registered;
|
|
21
45
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A renewal loop that is TERMINAL once stopped, and the one shape two files renew against.
|
|
2
|
+
// `heartbeat.ts` renews a job's lease and `worker-fleet-slots.ts` a fleet slot, and both decided a
|
|
3
|
+
// LOSS from an answer that arrived after the run had already finished cleanly: `stop()` cleared
|
|
4
|
+
// the interval, which does nothing to the request already on the wire. So a flag is what every
|
|
5
|
+
// branch after an `await` re-reads — the shape `settleWithin`'s `decided` uses in core.
|
|
6
|
+
|
|
7
|
+
export interface RenewalTimer {
|
|
8
|
+
/**
|
|
9
|
+
* True once `stop()` has been called. Read AFTER every await in the renewal body: a clean
|
|
10
|
+
* completion settles the row this renewal is fenced on, so the driver answering "not yours"
|
|
11
|
+
* past that point is a finished job, not a lost lease — and reporting it is an error-level page
|
|
12
|
+
* for a non-event, on exactly the signals that mean the queue re-delivered live work.
|
|
13
|
+
*/
|
|
14
|
+
stopped(): boolean;
|
|
15
|
+
/** Stop renewing, for the pass in flight as well as the next one. Idempotent. */
|
|
16
|
+
stop(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function startRenewalTimer(
|
|
20
|
+
intervalMs: number,
|
|
21
|
+
renew: () => void | Promise<void>,
|
|
22
|
+
): RenewalTimer {
|
|
23
|
+
let stopped = false;
|
|
24
|
+
const timer = setInterval(() => {
|
|
25
|
+
void renew();
|
|
26
|
+
}, intervalMs);
|
|
27
|
+
return {
|
|
28
|
+
stopped: () => stopped,
|
|
29
|
+
stop(): void {
|
|
30
|
+
if (stopped) return;
|
|
31
|
+
stopped = true;
|
|
32
|
+
clearInterval(timer);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// One retry decision from two questions the executor used to ask only half of: "are there
|
|
2
|
+
// attempts left?" (./retry) and "is this error worth trying again at all?" (core's classification).
|
|
3
|
+
// The backoff arithmetic stays in ./retry — nothing here recomputes a delay `nextRetry` owns.
|
|
4
|
+
|
|
5
|
+
import type { ErrorRetry } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_ERROR_RETRY,
|
|
8
|
+
declaredErrorRetry,
|
|
9
|
+
isErrorRetry,
|
|
10
|
+
isUltimateError,
|
|
11
|
+
} from '@ultimat3/core';
|
|
12
|
+
import { toMs } from './clock';
|
|
13
|
+
import type { Random, RetryDecision, RetryPolicy } from './retry';
|
|
14
|
+
import { DEFAULT_RETRY, nextRetry } from './retry';
|
|
15
|
+
|
|
16
|
+
/** Why this attempt was the last one. Absent while the job is still being retried. */
|
|
17
|
+
export type JobStopReason = 'terminal' | 'attempts-exhausted';
|
|
18
|
+
|
|
19
|
+
export interface JobRetryDecision extends RetryDecision {
|
|
20
|
+
readonly stoppedBy: JobStopReason | undefined;
|
|
21
|
+
/** The classification consulted, or `undefined` when nobody classified the thrown code. */
|
|
22
|
+
readonly classification: ErrorRetry | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The classification that was DECLARED for this throw, or `undefined` when there is none.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately not `error.retry` alone. That field is `init.retry ?? retryFor(code)` and
|
|
29
|
+
* `retryFor` fails closed, so every unclassified `UltimateError` already carries `terminal` —
|
|
30
|
+
* reading it would dead-letter the first attempt of every job in every app whose codes nobody has
|
|
31
|
+
* classified yet. So `terminal` counts only when it can have come from somewhere: an explicit
|
|
32
|
+
* per-instance override is indistinguishable from the default here, which is why an UNCLASSIFIED
|
|
33
|
+
* code carrying an instance `retry: 'terminal'` is read as unclassified. Register the code
|
|
34
|
+
* (`registerErrorRetry({ X_YOUR_CODE: 'terminal' })`) to have it honoured — one way, and the same
|
|
35
|
+
* way every other package declares it.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyThrown(error: unknown): ErrorRetry | undefined {
|
|
38
|
+
if (!isUltimateError(error)) return undefined;
|
|
39
|
+
const retry: unknown = error.retry;
|
|
40
|
+
if (!isErrorRetry(retry)) return undefined;
|
|
41
|
+
// Anything other than the fail-closed default can only have come from the code table or from an
|
|
42
|
+
// explicit override, so it is somebody's answer either way.
|
|
43
|
+
if (retry !== DEFAULT_ERROR_RETRY) return retry;
|
|
44
|
+
return declaredErrorRetry(error.code) === undefined ? undefined : retry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The delay a `retry-after` error NAMED, in ms. `retryAfterSeconds` on the error's `meta` is the
|
|
49
|
+
* framework's one spelling for it — `@ultimat3/http`'s `rateLimited` writes it and the 429's
|
|
50
|
+
* `Retry-After` header renders it — so a job and an HTTP client read the same number.
|
|
51
|
+
*/
|
|
52
|
+
export function statedDelayMs(error: unknown): number | undefined {
|
|
53
|
+
if (!isUltimateError(error)) return undefined;
|
|
54
|
+
const seconds: unknown = error.meta?.['retryAfterSeconds'];
|
|
55
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
56
|
+
return Math.round(seconds * 1_000);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Retry, dead-letter, and when. `terminal` stops here on the attempt that failed — the same code
|
|
61
|
+
* run again is the same answer, and the attempts left are a queue slot, a provider bill, and (the
|
|
62
|
+
* case that forced this) three more wrong passwords at a site that locks the account after three.
|
|
63
|
+
*
|
|
64
|
+
* Everything else keeps the attempt count in charge: `retry-after` only replaces the delay, never
|
|
65
|
+
* the ceiling, and an unclassified code takes exactly the path it took before this existed.
|
|
66
|
+
*/
|
|
67
|
+
export function nextRetryForError(
|
|
68
|
+
policy: RetryPolicy,
|
|
69
|
+
attempt: number,
|
|
70
|
+
error: unknown,
|
|
71
|
+
random?: Random,
|
|
72
|
+
): JobRetryDecision {
|
|
73
|
+
const classification = classifyThrown(error);
|
|
74
|
+
if (classification === 'terminal') {
|
|
75
|
+
return {
|
|
76
|
+
retry: false,
|
|
77
|
+
delayMs: 0,
|
|
78
|
+
// The policy still decides park-or-drop: `deadLetter: false` means this app does not keep
|
|
79
|
+
// failed jobs, and that is not a preference a classification gets to overturn.
|
|
80
|
+
deadLetter: policy.deadLetter ?? true,
|
|
81
|
+
nextAttempt: attempt,
|
|
82
|
+
stoppedBy: 'terminal',
|
|
83
|
+
classification,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const decision = nextRetry(policy, attempt, random);
|
|
88
|
+
if (!decision.retry) {
|
|
89
|
+
return { ...decision, stoppedBy: 'attempts-exhausted', classification };
|
|
90
|
+
}
|
|
91
|
+
if (classification !== 'retry-after')
|
|
92
|
+
return { ...decision, stoppedBy: undefined, classification };
|
|
93
|
+
|
|
94
|
+
const stated = statedDelayMs(error);
|
|
95
|
+
if (stated === undefined) return { ...decision, stoppedBy: undefined, classification };
|
|
96
|
+
// Clamped by the policy's own ceiling, which is what `maxDelay` is for: a responder naming a
|
|
97
|
+
// day is still a responder this deployment has not agreed to wait a day for.
|
|
98
|
+
const cap = toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay);
|
|
99
|
+
return { ...decision, delayMs: Math.min(stated, cap), stoppedBy: undefined, classification };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the job ROW records. `lastError` is the one failure field a row carries, so a dead letter
|
|
104
|
+
* that stopped at attempt 1 of 5 has to explain itself there or `x jobs show` reads as a silent
|
|
105
|
+
* early stop. Only the terminal verdict is appended: exhaustion is already legible from
|
|
106
|
+
* `attempt === maxAttempts`.
|
|
107
|
+
*/
|
|
108
|
+
export function recordedFailure(message: string, decision: JobRetryDecision): string {
|
|
109
|
+
return decision.stoppedBy === 'terminal'
|
|
110
|
+
? `${message} — not retried: this code is classified terminal, so every remaining attempt fails the same way`
|
|
111
|
+
: message;
|
|
112
|
+
}
|
package/src/retry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Retry arithmetic, kept pure so the schedule is testable and printable. `x jobs
|
|
2
|
-
// renders `retrySchedule()` verbatim — an agent should be able to see
|
|
3
|
-
// without running the queue.
|
|
1
|
+
// Retry arithmetic, kept pure so the schedule is testable and printable. `x jobs show <id>`
|
|
2
|
+
// renders `retrySchedule()` verbatim as `JobTrace.retryDelaysMs` — an agent should be able to see
|
|
3
|
+
// when attempt 5 lands without running the queue. There is no `x jobs schedule`: the subcommands
|
|
4
|
+
// are `ls`, `show`, `retry`, `cancel`, `drain`.
|
|
4
5
|
|
|
5
6
|
import type { DurationInput } from './clock';
|
|
6
7
|
import { toMs } from './clock';
|
package/src/steps.ts
CHANGED
|
@@ -12,7 +12,20 @@ import type { DurationInput } from './clock';
|
|
|
12
12
|
import { nowMs, toMs } from './clock';
|
|
13
13
|
import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The runtime list is the declaration and `StepStatus` is derived from it, the shape
|
|
17
|
+
* `BACKFILL_STATUSES` and `PRIMITIVE_KINDS` already have. A bare union cannot narrow a `text`
|
|
18
|
+
* column, so `driver-pg-rows.ts` cast one instead — and a cast that lands an unknown status on a
|
|
19
|
+
* record makes `stepRun`'s `existing?.status === 'completed'` false, which RE-EXECUTES a step
|
|
20
|
+
* this file promises runs once.
|
|
21
|
+
*/
|
|
22
|
+
export const STEP_STATUSES = ['completed', 'sleeping', 'waiting', 'failed'] as const;
|
|
23
|
+
|
|
24
|
+
export type StepStatus = (typeof STEP_STATUSES)[number];
|
|
25
|
+
|
|
26
|
+
/** Narrows a status read back out of a store. Never a cast — the list decides. */
|
|
27
|
+
export const isStepStatus = (value: string): value is StepStatus =>
|
|
28
|
+
(STEP_STATUSES as readonly string[]).includes(value);
|
|
16
29
|
|
|
17
30
|
export interface StepRecord {
|
|
18
31
|
readonly runId: string;
|
package/src/task.ts
CHANGED
|
@@ -54,6 +54,7 @@ export interface TaskDefinition {
|
|
|
54
54
|
*/
|
|
55
55
|
enqueue: (occurrenceMs: number) => readonly TaskEnqueueEntry[];
|
|
56
56
|
readonly catchUp?: CatchUpPolicy;
|
|
57
|
+
/** Whole occurrences per round, one or more. Zero fires nothing at all, and `task()` refuses it. */
|
|
57
58
|
readonly maxCatchUp?: number;
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -98,6 +99,9 @@ export interface TaskHandle {
|
|
|
98
99
|
const registry = new Map<string, TaskHandle>();
|
|
99
100
|
let anonymous = 0;
|
|
100
101
|
|
|
102
|
+
/** Occurrences one round may fire when neither the declaration nor a catch-up says otherwise. */
|
|
103
|
+
const DEFAULT_MAX_CATCH_UP = 10;
|
|
104
|
+
|
|
101
105
|
/** Job's store, for tasks: proof `task()` built the handle, plus whether it named itself. */
|
|
102
106
|
interface TaskOrigin {
|
|
103
107
|
readonly declaredName: boolean;
|
|
@@ -127,13 +131,26 @@ export function task(definition: TaskDefinition): TaskHandle {
|
|
|
127
131
|
`use the full zone id on task("${name}"), e.g. tz: 'America/Bogota' — list the valid ones with: bun -e "console.log(Intl.supportedValuesOf('timeZone').join('\\n'))"`,
|
|
128
132
|
);
|
|
129
133
|
|
|
134
|
+
// `maxCatchUp: 0` is not "no ceiling" — `occurrencesSince` walks
|
|
135
|
+
// `for (let i = 0; i < handle.maxCatchUp; i += 1)`, so zero (and any negative, and any fraction
|
|
136
|
+
// below one) returns an empty list on every round and the task NEVER fires: no error, no log
|
|
137
|
+
// line, no queue row, forever. Refused where it is written, exactly as `job()` refuses
|
|
138
|
+
// `concurrency: 0` and `createPacer` refuses `rate: 0`. `Number.isInteger` covers `NaN` and
|
|
139
|
+
// `Infinity` in the same predicate — an unbounded catch-up is a burst nobody declared.
|
|
140
|
+
assert(
|
|
141
|
+
definition.maxCatchUp === undefined ||
|
|
142
|
+
(Number.isInteger(definition.maxCatchUp) && definition.maxCatchUp >= 1),
|
|
143
|
+
`task "${name}" declares maxCatchUp ${String(definition.maxCatchUp)}, so no occurrence can ever fire`,
|
|
144
|
+
`set a whole maxCatchUp of 1 or more on task("${name}"), or omit the field for the default of ${DEFAULT_MAX_CATCH_UP}`,
|
|
145
|
+
);
|
|
146
|
+
|
|
130
147
|
const handle: TaskHandle = {
|
|
131
148
|
kind: 'task',
|
|
132
149
|
name,
|
|
133
150
|
cron: definition.cron,
|
|
134
151
|
tz: definition.tz,
|
|
135
152
|
catchUp: definition.catchUp ?? 'skip',
|
|
136
|
-
maxCatchUp: definition.maxCatchUp ??
|
|
153
|
+
maxCatchUp: definition.maxCatchUp ?? DEFAULT_MAX_CATCH_UP,
|
|
137
154
|
// `nowMs()` and not `Date.now()`: every reading of time in this package goes through a
|
|
138
155
|
// Clock so a frozen one cannot be bypassed.
|
|
139
156
|
entries: (occurrenceMs: number = nowMs()) => definition.enqueue(occurrenceMs),
|
|
@@ -215,8 +232,18 @@ export function nameTasks(record: Readonly<Record<string, TaskHandle>>): void {
|
|
|
215
232
|
for (const [exportName, handle] of Object.entries(record)) registerTask(exportName, handle);
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Code-unit compare, never `localeCompare`. This list is projected into `x.manifest.json`, which
|
|
237
|
+
* both tracked apps COMMIT and `x verify`'s drift step diffs byte for byte — and `localeCompare`
|
|
238
|
+
* with no locale argument answers from the runtime's ICU default and collation version, so the
|
|
239
|
+
* same source could sort two ways on two machines. `@ultimat3/http`'s `describeRoutes` states the
|
|
240
|
+
* same rule; the comparator is restated rather than imported because `http` is not below this
|
|
241
|
+
* package on the tier table.
|
|
242
|
+
*/
|
|
243
|
+
const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
244
|
+
|
|
218
245
|
export function registeredTasks(): readonly TaskHandle[] {
|
|
219
|
-
return [...registry.values()].sort((a, b) => a.name
|
|
246
|
+
return [...registry.values()].sort((a, b) => byName(a.name, b.name));
|
|
220
247
|
}
|
|
221
248
|
|
|
222
249
|
export function getTask(name: string): TaskHandle | undefined {
|
|
@@ -8,6 +8,7 @@ import type { ClaimedJob } from './driver';
|
|
|
8
8
|
import { getJob } from './job';
|
|
9
9
|
import type { HeldLease, LeaseStore } from './leases';
|
|
10
10
|
import { jobLeaseKey } from './leases';
|
|
11
|
+
import { startRenewalTimer } from './renewal-timer';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* A renewal that REJECTED is not a lost slot: there is a TTL behind it and the interval gets
|
|
@@ -79,21 +80,25 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
79
80
|
startRenewal(jobId, onLost) {
|
|
80
81
|
const slot = held.get(jobId);
|
|
81
82
|
if (slot === undefined) return noop;
|
|
82
|
-
const stop = (): void => {
|
|
83
|
-
clearInterval(timer);
|
|
84
|
-
};
|
|
85
83
|
// Renewed on the lease heartbeat's own interval and released in the same `finally`: one
|
|
86
84
|
// clock for "this worker still owns the job" and "this worker still owns the slot" is one
|
|
87
|
-
// fewer way for them to disagree.
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
// fewer way for them to disagree — and `timer.stopped()` is the same latch `heartbeat.ts`
|
|
86
|
+
// reads, for the same reason.
|
|
87
|
+
const timer = startRenewalTimer(options.renewIntervalMs, () =>
|
|
88
|
+
options.leases
|
|
90
89
|
?.renew(slot, options.ttlMs)
|
|
91
90
|
.then((renewed) => {
|
|
92
91
|
// `=== false`, never `!renewed`, for the reason `heartbeat.ts` reads `held` that way:
|
|
93
92
|
// a store written before this return value existed resolves `undefined`, and treating
|
|
94
93
|
// that as a loss would cancel every job on every renewal. Only an explicit no is one.
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
//
|
|
95
|
+
// `stopped()` re-read AFTER the await for the other half: the run settles, this timer
|
|
96
|
+
// is stopped and `worker.ts` releases the slot — so the renewal already on the wire
|
|
97
|
+
// finds the row gone and answers `false` for a job that FINISHED. Reported, that is
|
|
98
|
+
// `jobs.worker.slot-lost` at error and an abort on a controller `runSignal.dispose()`
|
|
99
|
+
// has already torn down: noise about a run nobody lost.
|
|
100
|
+
if (renewed !== false || timer.stopped()) return;
|
|
101
|
+
timer.stop();
|
|
97
102
|
logger.error('jobs.worker.slot-lost', {
|
|
98
103
|
workerId: options.workerId,
|
|
99
104
|
jobId,
|
|
@@ -102,9 +107,9 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
102
107
|
});
|
|
103
108
|
onLost?.(slot);
|
|
104
109
|
})
|
|
105
|
-
.catch(noop)
|
|
106
|
-
|
|
107
|
-
return stop;
|
|
110
|
+
.catch(noop),
|
|
111
|
+
);
|
|
112
|
+
return () => timer.stop();
|
|
108
113
|
},
|
|
109
114
|
|
|
110
115
|
async release(jobId) {
|
package/src/worker-run.ts
CHANGED
|
@@ -48,10 +48,13 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
48
48
|
const handle = getJob(claimed.name);
|
|
49
49
|
if (handle === undefined) {
|
|
50
50
|
// Park it, do not burn attempts: the job may well be registered by the pod next to this one.
|
|
51
|
+
// A genuine park — no worker in this deploy can run it — so it leaves the ready bucket, unlike
|
|
52
|
+
// a limiter shed, which is a job this fleet will pick up on its next pass.
|
|
51
53
|
await driver.nack(claimed.id, {
|
|
52
54
|
delayMs: 30_000,
|
|
53
55
|
error: `no job registered as "${claimed.name}"`,
|
|
54
56
|
countsAsAttempt: false,
|
|
57
|
+
park: true,
|
|
55
58
|
});
|
|
56
59
|
return unknownJob(claimed);
|
|
57
60
|
}
|