@ultimat3/jobs 1.2.0 → 3.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 +660 -0
- package/README.md +432 -17
- package/package.json +7 -5
- package/src/backfill-gate.ts +97 -0
- package/src/backfill-inspect.ts +73 -0
- package/src/backfill-ledger.ts +183 -0
- package/src/backfill-pass.ts +276 -0
- package/src/backfill-pending.ts +131 -0
- package/src/backfill-rate.ts +109 -0
- package/src/backfill-registry.ts +108 -0
- package/src/backfill-scope.ts +70 -0
- package/src/backfill.ts +213 -0
- package/src/driver-memory.ts +61 -9
- package/src/driver-nats.ts +2 -1
- package/src/driver-pg-ddl.ts +191 -0
- package/src/driver-pg-rows.ts +123 -0
- package/src/driver-pg-sql.ts +312 -55
- package/src/driver-pg.ts +138 -92
- package/src/driver-redis.ts +2 -1
- package/src/driver.ts +91 -7
- package/src/errors.ts +314 -5
- package/src/events-pg.ts +121 -0
- package/src/events.ts +7 -1
- package/src/execute.ts +308 -0
- package/src/heartbeat.ts +148 -0
- package/src/index.ts +128 -27
- package/src/inspect.ts +43 -2
- package/src/job.ts +127 -3
- package/src/leases.ts +90 -0
- package/src/limits.ts +0 -0
- package/src/metrics.ts +35 -0
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +188 -0
- package/src/outbox.ts +204 -59
- package/src/register.ts +1 -1
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/retry.ts +6 -1
- package/src/run-signal.ts +50 -0
- package/src/scheduler-pg.ts +103 -0
- package/src/scheduler.ts +159 -245
- package/src/steps.ts +155 -31
- package/src/task.ts +239 -0
- package/src/tenant.ts +61 -0
- package/src/worker-fleet-slots.ts +129 -0
- package/src/worker-run.ts +132 -0
- package/src/worker.ts +207 -190
|
@@ -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
|
@@ -48,7 +48,12 @@ export function backoffDelayMs(policy: RetryPolicy, attempt: number, random?: Ra
|
|
|
48
48
|
else raw = base * 2 ** (step - 1);
|
|
49
49
|
|
|
50
50
|
const capped = Math.min(raw, cap);
|
|
51
|
-
|
|
51
|
+
// `?? DEFAULT_RETRY.jitter`, like every other option above. It read `!== true`, so an omitted
|
|
52
|
+
// `jitter` meant OFF while the field's own doc says "Equal jitter … by default" — a burst of
|
|
53
|
+
// failures then retried in lockstep, which is the thundering herd the default exists to break.
|
|
54
|
+
// Masked for jobs declared through `job()` (it merges the defaults) and live for every direct
|
|
55
|
+
// caller of this exported function, `retrySchedule` included.
|
|
56
|
+
if ((policy.jitter ?? DEFAULT_RETRY.jitter) !== true) return Math.round(capped);
|
|
52
57
|
const roll = (random ?? Math.random)();
|
|
53
58
|
return Math.round(capped / 2 + (capped / 2) * roll);
|
|
54
59
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The signal ONE job run is cancelled by: the caller's `Ctx.signal`, the lease heartbeat, and
|
|
2
|
+
// whatever else the worker holds over the run. `AbortSignal.any` composed the same thing and could
|
|
3
|
+
// not be undone — an app wiring a process-lifetime controller into `WorkerOptions.context()` grew
|
|
4
|
+
// one composite per job for the life of the worker, and nothing could abort the result either.
|
|
5
|
+
|
|
6
|
+
export interface RunSignal {
|
|
7
|
+
/** Handed to the run as `ctx.signal`; dies with the run. */
|
|
8
|
+
readonly signal: AbortSignal;
|
|
9
|
+
/** Cancel this run. The first reason wins, exactly as `AbortController.abort` already does. */
|
|
10
|
+
abort(reason: unknown): void;
|
|
11
|
+
/**
|
|
12
|
+
* Stop following the sources. Idempotent, and it never aborts: a run that settled leaves its
|
|
13
|
+
* signal in whatever state it ended in, it just stops being the caller's problem.
|
|
14
|
+
*/
|
|
15
|
+
dispose(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One controller per run, following every source it was given. A source already aborted aborts the
|
|
20
|
+
* run at composition, carrying its own reason — the same semantics `AbortSignal.any` has, minus
|
|
21
|
+
* the part that cannot be handed back.
|
|
22
|
+
*
|
|
23
|
+
* `undefined` and a non-`AbortSignal` are both skipped rather than refused: `Ctx.signal` is
|
|
24
|
+
* non-optional in the type and still arrives missing across a cast (`@ultimat3/http`'s `asCtx`, a
|
|
25
|
+
* test's `{} as Ctx`), and a job that crashed on a missing field is worse than a job with no
|
|
26
|
+
* caller to follow.
|
|
27
|
+
*/
|
|
28
|
+
export function createRunSignal(sources: readonly (AbortSignal | undefined)[]): RunSignal {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const detach: (() => void)[] = [];
|
|
31
|
+
|
|
32
|
+
for (const source of sources) {
|
|
33
|
+
if (!(source instanceof AbortSignal)) continue;
|
|
34
|
+
if (source.aborted) {
|
|
35
|
+
controller.abort(source.reason);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const forward = (): void => controller.abort(source.reason);
|
|
39
|
+
source.addEventListener('abort', forward, { once: true });
|
|
40
|
+
detach.push(() => source.removeEventListener('abort', forward));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
abort: (reason) => controller.abort(reason),
|
|
46
|
+
dispose: () => {
|
|
47
|
+
for (const off of detach.splice(0)) off();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The scheduler's durable halves: the watermark it decides "missed" against, and leader election
|
|
2
|
+
// that survives a pooled connection. Both were `Map`s and a `() => true` in the shipped boot, and
|
|
3
|
+
// both failures are silent — a redeployed pod arms to tomorrow and never reports the 03:00 run the
|
|
4
|
+
// pod it replaced dropped, and two pods in a rolling update both dispatch every task.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { uuid } from '@ultimat3/core';
|
|
8
|
+
import { nowMs } from './clock';
|
|
9
|
+
import type { PgExecutor } from './driver-pg';
|
|
10
|
+
import {
|
|
11
|
+
SQL_LEADER_ACQUIRE,
|
|
12
|
+
SQL_LEADER_RELEASE,
|
|
13
|
+
SQL_SCHEDULER_STATE_GET,
|
|
14
|
+
SQL_SCHEDULER_STATE_MARK,
|
|
15
|
+
} from './driver-pg-sql';
|
|
16
|
+
import type { LeaderElection, SchedulerState } from './scheduler';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `x_scheduler_state`, one row per task. The watermark is what makes "missed" a decidable
|
|
20
|
+
* question at all: `catchUp`, `maxCatchUp` and `run-once` are all relative to it, so a scheduler
|
|
21
|
+
* whose watermark dies with its process has none of them — it takes the arming branch on every
|
|
22
|
+
* boot and drops every occurrence between the two processes with nothing logged.
|
|
23
|
+
*/
|
|
24
|
+
export function pgSchedulerState(executor: PgExecutor): SchedulerState {
|
|
25
|
+
return {
|
|
26
|
+
async lastFiredAt(taskName) {
|
|
27
|
+
const rows = await executor.query<{ last_fired_at: number | string | null }>(
|
|
28
|
+
SQL_SCHEDULER_STATE_GET,
|
|
29
|
+
[taskName],
|
|
30
|
+
);
|
|
31
|
+
const value = rows[0]?.last_fired_at;
|
|
32
|
+
return value === null || value === undefined ? undefined : Number(value);
|
|
33
|
+
},
|
|
34
|
+
async markFired(taskName, occurrenceMs) {
|
|
35
|
+
await executor.query(SQL_SCHEDULER_STATE_MARK, [taskName, occurrenceMs]);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PgLeaseLeaderOptions {
|
|
41
|
+
readonly executor: PgExecutor;
|
|
42
|
+
/** One key per elected role. Default `'scheduler'`. */
|
|
43
|
+
readonly lockKey?: string;
|
|
44
|
+
/** This node's identity. Defaults to a per-process uuid — never a hostname a pod reuses. */
|
|
45
|
+
readonly holder?: string;
|
|
46
|
+
/**
|
|
47
|
+
* How long a grant survives without a renewal. Must be comfortably longer than the scheduler's
|
|
48
|
+
* tick interval, or a slow round loses the lock to a standby mid-dispatch. Default 30s against
|
|
49
|
+
* a 1s tick.
|
|
50
|
+
*/
|
|
51
|
+
readonly ttlMs?: number;
|
|
52
|
+
readonly clock?: Clock;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_LEADER_TTL_MS = 30_000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Leader election as an expiring row, which is what makes it correct on the executor this package
|
|
59
|
+
* is actually handed: a POOL. `createPgLeader`'s `pg_try_advisory_lock` is session-scoped, and a
|
|
60
|
+
* session on a pool ends the moment the connection goes back — so every node reads itself as
|
|
61
|
+
* leader and a rolling update double-fires every task. `@ultimat3/realtime`'s `PgAdvisoryLock`
|
|
62
|
+
* solves the same problem by owning its connection; this package holds no wire protocol, so it
|
|
63
|
+
* solves it with a row instead.
|
|
64
|
+
*
|
|
65
|
+
* `acquire()` is also the RENEWAL, so the scheduler calling it every round both keeps the lease
|
|
66
|
+
* alive and learns the round it stops being leader. A crashed node's lease is reclaimed by expiry
|
|
67
|
+
* with nothing to clean up, which is the one property the advisory lock had and a plain
|
|
68
|
+
* `insert ... on conflict do nothing` would not.
|
|
69
|
+
*/
|
|
70
|
+
export function createPgLeaseLeader(options: PgLeaseLeaderOptions): LeaderElection {
|
|
71
|
+
const lockKey = options.lockKey ?? 'scheduler';
|
|
72
|
+
const holder = options.holder ?? `scheduler-${uuid()}`;
|
|
73
|
+
const ttlMs = options.ttlMs ?? DEFAULT_LEADER_TTL_MS;
|
|
74
|
+
return {
|
|
75
|
+
async acquire() {
|
|
76
|
+
const rows = await options.executor.query<{ holder: string }>(SQL_LEADER_ACQUIRE, [
|
|
77
|
+
lockKey,
|
|
78
|
+
holder,
|
|
79
|
+
ttlMs,
|
|
80
|
+
]);
|
|
81
|
+
return rows[0]?.holder === holder;
|
|
82
|
+
},
|
|
83
|
+
async release() {
|
|
84
|
+
await options.executor.query(SQL_LEADER_RELEASE, [lockKey, holder]);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Exposed so a test — and `x jobs ls` — can say which node currently holds the lease. */
|
|
90
|
+
export async function currentLeader(
|
|
91
|
+
executor: PgExecutor,
|
|
92
|
+
lockKey = 'scheduler',
|
|
93
|
+
clock?: Clock,
|
|
94
|
+
): Promise<string | undefined> {
|
|
95
|
+
const rows = await executor.query<{ holder: string; expires_at: number | string }>(
|
|
96
|
+
`select holder, (extract(epoch from expires_at) * 1000)::bigint as expires_at
|
|
97
|
+
from x_scheduler_leader where lock_key = $1`,
|
|
98
|
+
[lockKey],
|
|
99
|
+
);
|
|
100
|
+
const row = rows[0];
|
|
101
|
+
if (row === undefined) return undefined;
|
|
102
|
+
return Number(row.expires_at) > nowMs(clock) ? row.holder : undefined;
|
|
103
|
+
}
|