@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/scheduler-pg.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
// both failures are silent — a redeployed pod arms to tomorrow and never reports the 03:00 run the
|
|
4
4
|
// pod it replaced dropped, and two pods in a rolling update both dispatch every task.
|
|
5
5
|
|
|
6
|
-
import type { Clock } from '@ultimat3/core';
|
|
7
|
-
import { finiteOption,
|
|
6
|
+
import type { Clock, PgExecutor } from '@ultimat3/core';
|
|
7
|
+
import { finiteOption, uuidV7 } from '@ultimat3/core';
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import { DEFAULT_QUEUE } from './driver';
|
|
10
|
-
import type { PgExecutor } from './driver-pg';
|
|
11
10
|
import { SQL_SCHEDULER_FIRE } from './driver-pg-operator-sql';
|
|
12
11
|
import {
|
|
13
12
|
SQL_LEADER_ACQUIRE,
|
|
@@ -25,7 +24,7 @@ import { fireThroughDriver } from './scheduler-state';
|
|
|
25
24
|
* whose watermark dies with its process has none of them — it takes the arming branch on every
|
|
26
25
|
* boot and drops every occurrence between the two processes with nothing logged.
|
|
27
26
|
*/
|
|
28
|
-
export function
|
|
27
|
+
export function postgresSchedulerState(executor: PgExecutor): SchedulerState {
|
|
29
28
|
const state: SchedulerState = {
|
|
30
29
|
async lastFiredAt(taskName) {
|
|
31
30
|
const rows = await executor.query<{ last_fired_at: number | string | null }>(
|
|
@@ -42,12 +41,11 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
|
|
|
42
41
|
// One statement only when the queue IS this database. A host that swapped the driver keeps
|
|
43
42
|
// the watermark here and its jobs elsewhere, and no statement spans the two.
|
|
44
43
|
if (driver.name !== 'pg') return fireThroughDriver(state, driver, fire);
|
|
45
|
-
const ids = fire.jobs.map(() =>
|
|
44
|
+
const ids = fire.jobs.map(() => uuidV7());
|
|
46
45
|
const rows = await executor.query<{
|
|
47
46
|
fired: number | string;
|
|
48
47
|
id: string | null;
|
|
49
48
|
run_id: string | null;
|
|
50
|
-
idempotency_key: string | null;
|
|
51
49
|
}>(SQL_SCHEDULER_FIRE, [
|
|
52
50
|
fire.task,
|
|
53
51
|
fire.occurrenceMs,
|
|
@@ -58,23 +56,28 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
|
|
|
58
56
|
queue: request.queue || DEFAULT_QUEUE,
|
|
59
57
|
input: request.input ?? null,
|
|
60
58
|
idempotency_key: request.idempotencyKey,
|
|
61
|
-
run_id: request.runId ??
|
|
59
|
+
run_id: request.runId ?? uuidV7(),
|
|
62
60
|
max_attempts: request.maxAttempts,
|
|
63
61
|
})),
|
|
64
62
|
),
|
|
63
|
+
// Where the watermark lands when that is past the occurrence (`run-once`): ONE statement.
|
|
64
|
+
fire.watermarkMs ?? null,
|
|
65
65
|
]);
|
|
66
66
|
if (Number(rows[0]?.fired ?? 0) === 0) return undefined;
|
|
67
|
+
// Keyed on the id THIS statement minted (`ids[index]`), never on the idempotency key: the
|
|
68
|
+
// key is unique per (name, tenant, key), so two jobs of one occurrence may share it, and a
|
|
69
|
+
// map by key told both callers the second one's id — where the memory store tells each its own.
|
|
67
70
|
const queued = new Map(
|
|
68
71
|
rows.flatMap((row) =>
|
|
69
|
-
row.id === null || row.run_id === null
|
|
72
|
+
row.id === null || row.run_id === null
|
|
70
73
|
? []
|
|
71
|
-
: [[row.
|
|
74
|
+
: [[row.id.toLowerCase(), { id: row.id, runId: row.run_id }] as const],
|
|
72
75
|
),
|
|
73
76
|
);
|
|
74
77
|
// A job absent from the answer met a LIVE row of its key — a fire from before this
|
|
75
78
|
// statement existed, still in flight. It is that row's work, so it is reported deduped.
|
|
76
|
-
return fire.jobs.map((
|
|
77
|
-
const inserted = queued.get(
|
|
79
|
+
return fire.jobs.map((_, index) => {
|
|
80
|
+
const inserted = queued.get(String(ids[index]).toLowerCase());
|
|
78
81
|
return inserted === undefined
|
|
79
82
|
? { id: '', runId: '', deduped: true }
|
|
80
83
|
: { ...inserted, deduped: false };
|
|
@@ -84,7 +87,7 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
|
|
|
84
87
|
return state;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
export interface
|
|
90
|
+
export interface PostgresLeaseLeaderOptions {
|
|
88
91
|
readonly executor: PgExecutor;
|
|
89
92
|
/** One key per elected role. Default `'scheduler'`. */
|
|
90
93
|
readonly lockKey?: string;
|
|
@@ -106,9 +109,9 @@ export const LEASE_RENEWALS_PER_TTL = 3;
|
|
|
106
109
|
|
|
107
110
|
/**
|
|
108
111
|
* Leader election as an expiring row, which is what makes it correct on the executor this package
|
|
109
|
-
* is actually handed: a POOL. `
|
|
112
|
+
* is actually handed: a POOL. `postgresLeader`'s `pg_try_advisory_lock` is session-scoped, and a
|
|
110
113
|
* session on a pool ends the moment the connection goes back — so every node reads itself as
|
|
111
|
-
* leader and a rolling update double-fires every task. `@ultimat3/realtime`'s `
|
|
114
|
+
* leader and a rolling update double-fires every task. `@ultimat3/realtime`'s `postgresAdvisoryLock()`
|
|
112
115
|
* solves the same problem by owning its connection; this package holds no wire protocol, so it
|
|
113
116
|
* solves it with a row instead.
|
|
114
117
|
*
|
|
@@ -117,9 +120,9 @@ export const LEASE_RENEWALS_PER_TTL = 3;
|
|
|
117
120
|
* with nothing to clean up, which is the one property the advisory lock had and a plain
|
|
118
121
|
* `insert ... on conflict do nothing` would not.
|
|
119
122
|
*/
|
|
120
|
-
export function
|
|
123
|
+
export function postgresLeaseLeader(options: PostgresLeaseLeaderOptions): LeaderElection {
|
|
121
124
|
const lockKey = options.lockKey ?? 'scheduler';
|
|
122
|
-
const holder = options.holder ?? `scheduler-${
|
|
125
|
+
const holder = options.holder ?? `scheduler-${uuidV7()}`;
|
|
123
126
|
const ttlMs = finiteOption(
|
|
124
127
|
'the pg scheduler lease',
|
|
125
128
|
'ttlMs',
|
package/src/scheduler-state.ts
CHANGED
|
@@ -9,6 +9,13 @@ import type { EnqueueRequest, EnqueueResult, JobDriver } from './driver';
|
|
|
9
9
|
export interface ScheduledFire {
|
|
10
10
|
readonly task: string;
|
|
11
11
|
readonly occurrenceMs: number;
|
|
12
|
+
/**
|
|
13
|
+
* Where the watermark LANDS, when that is past the occurrence: `run-once` runs the earliest
|
|
14
|
+
* missed occurrence and drops the rest, and dropping is moving the watermark past them — in the
|
|
15
|
+
* same write, or a crash between the two fires a second "one catch-up". The fence is still the
|
|
16
|
+
* occurrence. Omitted, the watermark lands on the occurrence.
|
|
17
|
+
*/
|
|
18
|
+
readonly watermarkMs?: number;
|
|
12
19
|
readonly jobs: readonly EnqueueRequest[];
|
|
13
20
|
}
|
|
14
21
|
|
|
@@ -32,7 +39,7 @@ export interface SchedulerState {
|
|
|
32
39
|
/**
|
|
33
40
|
* The fire for a store that cannot share a statement with the queue: the watermark is checked,
|
|
34
41
|
* the jobs go through `driver.enqueue`, and the watermark moves last. Not atomic across a crash —
|
|
35
|
-
* which is why `
|
|
42
|
+
* which is why `postgresSchedulerState` does not use it against the pg driver — but exact for the
|
|
36
43
|
* memory pair, where nothing can fail between the steps and no second process exists.
|
|
37
44
|
*/
|
|
38
45
|
export async function fireThroughDriver(
|
|
@@ -44,14 +51,14 @@ export async function fireThroughDriver(
|
|
|
44
51
|
if (last !== undefined && last >= fire.occurrenceMs) return undefined;
|
|
45
52
|
const results: EnqueueResult[] = [];
|
|
46
53
|
for (const request of fire.jobs) results.push(await driver.enqueue(request));
|
|
47
|
-
await state.markFired(fire.task, fire.occurrenceMs);
|
|
54
|
+
await state.markFired(fire.task, fire.watermarkMs ?? fire.occurrenceMs);
|
|
48
55
|
// What an operator reads as "last fired". After the watermark, so a record never names an
|
|
49
56
|
// occurrence a crash would fire again.
|
|
50
57
|
await driver.introspect?.recordTaskFire({ task: fire.task, occurrenceMs: fire.occurrenceMs });
|
|
51
58
|
return results;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
export function
|
|
61
|
+
export function memorySchedulerState(): SchedulerState {
|
|
55
62
|
const fired = new Map<string, number>();
|
|
56
63
|
const state: SchedulerState = {
|
|
57
64
|
lastFiredAt: (taskName) => Promise.resolve(fired.get(taskName)),
|
package/src/scheduler.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// it owes and enqueues their jobs. The `task` primitive it reads lives in `task.ts`.
|
|
3
3
|
//
|
|
4
4
|
// Exactly one node dispatches per tick, enforced by leader election. Multi-node that is an
|
|
5
|
-
// EXPIRING LEASE ROW in `x_scheduler_leader` (`
|
|
5
|
+
// EXPIRING LEASE ROW in `x_scheduler_leader` (`postgresLeaseLeader`), never an advisory lock: an
|
|
6
6
|
// advisory lock is held by the SESSION, not by this process — it outlives every transaction and is
|
|
7
7
|
// released only by an explicit unlock, the pool's reset on release, or the connection dying, and
|
|
8
8
|
// the next round may run on a different connection. So a node can neither renew it nor prove it
|
|
@@ -31,7 +31,7 @@ import type { LeaderElection } from './scheduler-leader';
|
|
|
31
31
|
import { soleLeader } from './scheduler-leader';
|
|
32
32
|
import { defaultCronResolver, latestOccurrence, occurrencesIn } from './scheduler-occurrences';
|
|
33
33
|
import type { SchedulerState } from './scheduler-state';
|
|
34
|
-
import {
|
|
34
|
+
import { memorySchedulerState } from './scheduler-state';
|
|
35
35
|
import type { TaskHandle, TaskJobResult } from './task';
|
|
36
36
|
import { registeredTasks } from './task';
|
|
37
37
|
|
|
@@ -89,11 +89,11 @@ export interface Scheduler {
|
|
|
89
89
|
nextRunFor(handle: TaskHandle, from?: Date): Date;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export function
|
|
93
|
-
const schedulerState = options.state ??
|
|
92
|
+
export function jobScheduler(options: SchedulerOptions): Scheduler {
|
|
93
|
+
const schedulerState = options.state ?? memorySchedulerState();
|
|
94
94
|
const resolveCron = options.cron ?? defaultCronResolver;
|
|
95
95
|
const tickIntervalMs = finiteOption(
|
|
96
|
-
'
|
|
96
|
+
'jobScheduler',
|
|
97
97
|
'tickIntervalMs',
|
|
98
98
|
options.tickIntervalMs ?? 1_000,
|
|
99
99
|
);
|
|
@@ -144,6 +144,7 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
144
144
|
handle: TaskHandle,
|
|
145
145
|
occurrenceMs: number,
|
|
146
146
|
catchUp: boolean,
|
|
147
|
+
watermarkMs?: number,
|
|
147
148
|
): Promise<DispatchedOccurrence | undefined> => {
|
|
148
149
|
// The occurrence, not `at`: a catch-up dispatch runs long after the instant it fires for,
|
|
149
150
|
// and the payload has to describe the occurrence the email/report claims to be about.
|
|
@@ -166,6 +167,7 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
166
167
|
const results = await schedulerState.fire(options.driver, {
|
|
167
168
|
task: handle.name,
|
|
168
169
|
occurrenceMs,
|
|
170
|
+
...(watermarkMs === undefined ? {} : { watermarkMs }),
|
|
169
171
|
jobs: requests,
|
|
170
172
|
});
|
|
171
173
|
if (results === undefined) {
|
|
@@ -323,16 +325,13 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
323
325
|
if (handle.catchUp === 'run-once') {
|
|
324
326
|
const first = due[0];
|
|
325
327
|
if (first !== undefined) {
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
// definition, and this policy fires none of them.
|
|
334
|
-
if (first !== at) await schedulerState.markFired(handle.name, at);
|
|
335
|
-
if (ran) remember(handle, Math.max(first, at));
|
|
328
|
+
// "One catch-up" means the rest are DROPPED, and dropping an occurrence is moving the
|
|
329
|
+
// watermark past it: left on the occurrence that RAN, the next round found 2..n still
|
|
330
|
+
// due and fired one per tick — 24 nightly digests a second apart after a day down.
|
|
331
|
+
// `at` rather than the last element of `due`, which `maxCatchUp` truncates. It rides
|
|
332
|
+
// the FIRE (`watermarkMs`): a `markFired` behind it was a second write, and a scheduler
|
|
333
|
+
// that died between the two handed its successor another catch-up.
|
|
334
|
+
if (fired(await dispatch(handle, first, due.length > 1, at))) remember(handle, at);
|
|
336
335
|
}
|
|
337
336
|
continue;
|
|
338
337
|
}
|
package/src/steps-memory.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// The in-memory `StepStore`: what `
|
|
1
|
+
// The in-memory `StepStore`: what `memoryJobDriver` and every runner test persist steps into.
|
|
2
2
|
// Split from `steps.ts` at the file-size ceiling, along the seam the pg driver already draws —
|
|
3
3
|
// `driver-pg.ts` holds the Postgres store, this file the map-backed one, `steps.ts` the runner
|
|
4
4
|
// both are handed to. Same contract, and `steps.test.ts` is where the contract is pinned.
|
|
5
5
|
|
|
6
6
|
import type { StepRecord, StepStore } from './steps';
|
|
7
7
|
|
|
8
|
-
export function
|
|
8
|
+
export function memoryStepStore(): StepStore {
|
|
9
9
|
const byRun = new Map<string, Map<string, StepRecord>>();
|
|
10
10
|
const runOf = (runId: string): Map<string, StepRecord> => {
|
|
11
11
|
let run = byRun.get(runId);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A run that unwinds on purpose: the `StepSuspension` a `sleep` or `waitForEvent` throws, and the
|
|
2
|
+
// marker a timed-out wait persists. Split from `steps.ts` at the file-size ceiling — that file is
|
|
3
|
+
// the runner; this is the control flow the worker reads off what the runner threw.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Control flow, not a failure: it must never be logged as an error or counted as an attempt.
|
|
7
|
+
* Branded by string rather than `instanceof` so two copies of this module still agree.
|
|
8
|
+
*/
|
|
9
|
+
export class StepSuspension extends Error {
|
|
10
|
+
static readonly brand = 'ultimate.jobs.suspension';
|
|
11
|
+
readonly brand: string = StepSuspension.brand;
|
|
12
|
+
readonly step: string;
|
|
13
|
+
readonly resumeAt: number;
|
|
14
|
+
readonly reason: 'sleep' | 'event';
|
|
15
|
+
|
|
16
|
+
constructor(input: { step: string; resumeAt: number; reason: 'sleep' | 'event' }) {
|
|
17
|
+
super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
|
|
18
|
+
this.name = 'StepSuspension';
|
|
19
|
+
this.step = input.step;
|
|
20
|
+
this.resumeAt = input.resumeAt;
|
|
21
|
+
this.reason = input.reason;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a timed-out `waitForEvent` persists, and maps back to `undefined` on replay. `undefined`
|
|
27
|
+
* itself cannot survive a store — the pg driver writes `JSON.stringify(output ?? null)` — so the
|
|
28
|
+
* timeout replayed as `null` and `evt === undefined` read as "an event arrived". A namespaced key
|
|
29
|
+
* rather than a bare `{ timedOut: true }`, so no event payload can be mistaken for the marker.
|
|
30
|
+
*/
|
|
31
|
+
export const WAIT_TIMED_OUT = Object.freeze<Record<string, true>>({
|
|
32
|
+
'~ultimate.waitTimedOut': true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export function isWaitTimedOut(output: unknown): boolean {
|
|
36
|
+
return (
|
|
37
|
+
typeof output === 'object' &&
|
|
38
|
+
output !== null &&
|
|
39
|
+
Object.keys(output).length === 1 &&
|
|
40
|
+
(output as Record<string, unknown>)['~ultimate.waitTimedOut'] === true
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isStepSuspension(error: unknown): error is StepSuspension {
|
|
45
|
+
return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
|
|
46
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A step's own ceiling: the race between a step body and `stepTimeout`. Split from `steps.ts` at
|
|
2
|
+
// the file-size ceiling — that file is the runner, and this is the one timer it arms.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A step's own ceiling. It ABORTS before it rejects, in that order: `run()` fails the step on this
|
|
6
|
+
* rejection and the job retries, so a body still holding a socket open past the deadline would be
|
|
7
|
+
* racing the attempt that replaced it. Cancelling first is the only thing that can stop it.
|
|
8
|
+
*/
|
|
9
|
+
export function withStepTimeout<T>(
|
|
10
|
+
work: Promise<T> | T,
|
|
11
|
+
timeoutMs: number | undefined,
|
|
12
|
+
deadline: AbortController,
|
|
13
|
+
error: () => Error,
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
|
|
16
|
+
return new Promise<T>((resolve, reject) => {
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
const failure = error();
|
|
19
|
+
deadline.abort(failure);
|
|
20
|
+
reject(failure);
|
|
21
|
+
}, timeoutMs);
|
|
22
|
+
Promise.resolve(work).then(
|
|
23
|
+
(value) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve(value);
|
|
26
|
+
},
|
|
27
|
+
(cause) => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
reject(cause);
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
}
|
package/src/steps.ts
CHANGED
|
@@ -7,12 +7,21 @@
|
|
|
7
7
|
// catches it and re-queues the job for `resumeAt` instead of holding a process for three days.
|
|
8
8
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
|
-
import { finiteOption, logger, renderThrowable } from '@ultimat3/core';
|
|
10
|
+
import { finiteOption, isUltimateError, logger, renderThrowable } from '@ultimat3/core';
|
|
11
11
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
12
12
|
import type { DurationInput } from './clock';
|
|
13
13
|
import { finiteDurationMs, nowMs } from './clock';
|
|
14
14
|
import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
|
|
15
15
|
import { createRunSignal } from './run-signal';
|
|
16
|
+
import {
|
|
17
|
+
isStepSuspension,
|
|
18
|
+
isWaitTimedOut,
|
|
19
|
+
StepSuspension,
|
|
20
|
+
WAIT_TIMED_OUT,
|
|
21
|
+
} from './steps-suspension';
|
|
22
|
+
import { withStepTimeout } from './steps-timeout';
|
|
23
|
+
|
|
24
|
+
export { isStepSuspension, StepSuspension } from './steps-suspension';
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* The runtime list is the declaration and `StepStatus` is derived from it, the shape
|
|
@@ -45,9 +54,23 @@ export interface StepRecord {
|
|
|
45
54
|
readonly error?: string;
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
/** The claim a step write is made under: `claimOf(claimed)`, plus the row it names. */
|
|
58
|
+
export interface StepFence {
|
|
59
|
+
readonly job: string;
|
|
60
|
+
readonly jobId: string;
|
|
61
|
+
readonly workerId: string;
|
|
62
|
+
readonly claim: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
export interface StepStore {
|
|
49
66
|
get(runId: string, name: string): Promise<StepRecord | undefined>;
|
|
50
|
-
|
|
67
|
+
/**
|
|
68
|
+
* With `by`, the write lands only while that claim still holds the row (`running`, same worker,
|
|
69
|
+
* same claim ordinal — `SQL_ACK`'s fence) and is `X_JOB_LEASE_LOST` otherwise: the in-process
|
|
70
|
+
* signal cannot stop a body in ANOTHER process whose lease lapsed. Without it the write is
|
|
71
|
+
* unfenced — a transfer (`x jobs drain`) or a runner driven by hand, with no row to hold.
|
|
72
|
+
*/
|
|
73
|
+
put(record: StepRecord, by?: StepFence): Promise<void>;
|
|
51
74
|
list(runId: string): Promise<readonly StepRecord[]>;
|
|
52
75
|
del(runId: string, name: string): Promise<void>;
|
|
53
76
|
clear(runId: string): Promise<void>;
|
|
@@ -55,6 +78,13 @@ export interface StepStore {
|
|
|
55
78
|
|
|
56
79
|
/** The slice of the event bus a waiting step needs. Implemented by `events.ts`. */
|
|
57
80
|
export interface EventLookup {
|
|
81
|
+
/**
|
|
82
|
+
* The bus's OWN clock, in epoch ms: what a publish issued now would be stamped with. A consumer
|
|
83
|
+
* that wants "published after I asked" takes its "asked at" from here and never from its own
|
|
84
|
+
* process — `publishedAt` is this clock's, and two clocks compared is a skew between pods
|
|
85
|
+
* deciding which answers count. `waitForEvent` stamps a NEW wait with it.
|
|
86
|
+
*/
|
|
87
|
+
now(): Promise<number>;
|
|
58
88
|
find(
|
|
59
89
|
event: string,
|
|
60
90
|
correlationKey: string | undefined,
|
|
@@ -90,53 +120,12 @@ export interface StepApi {
|
|
|
90
120
|
): Promise<T | undefined>;
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
/**
|
|
94
|
-
* Control flow, not a failure: it must never be logged as an error or counted as an attempt.
|
|
95
|
-
* Branded by string rather than `instanceof` so two copies of this module still agree.
|
|
96
|
-
*/
|
|
97
|
-
export class StepSuspension extends Error {
|
|
98
|
-
static readonly brand = 'ultimate.jobs.suspension';
|
|
99
|
-
readonly brand: string = StepSuspension.brand;
|
|
100
|
-
readonly step: string;
|
|
101
|
-
readonly resumeAt: number;
|
|
102
|
-
readonly reason: 'sleep' | 'event';
|
|
103
|
-
|
|
104
|
-
constructor(input: { step: string; resumeAt: number; reason: 'sleep' | 'event' }) {
|
|
105
|
-
super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
|
|
106
|
-
this.name = 'StepSuspension';
|
|
107
|
-
this.step = input.step;
|
|
108
|
-
this.resumeAt = input.resumeAt;
|
|
109
|
-
this.reason = input.reason;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* What a timed-out `waitForEvent` persists, and maps back to `undefined` on replay. `undefined`
|
|
115
|
-
* itself cannot survive a store — the pg driver writes `JSON.stringify(output ?? null)` — so the
|
|
116
|
-
* timeout replayed as `null` and `evt === undefined` read as "an event arrived". A namespaced key
|
|
117
|
-
* rather than a bare `{ timedOut: true }`, so no event payload can be mistaken for the marker.
|
|
118
|
-
*/
|
|
119
|
-
const WAIT_TIMED_OUT = Object.freeze<Record<string, true>>({
|
|
120
|
-
'~ultimate.waitTimedOut': true,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
function isWaitTimedOut(output: unknown): boolean {
|
|
124
|
-
return (
|
|
125
|
-
typeof output === 'object' &&
|
|
126
|
-
output !== null &&
|
|
127
|
-
Object.keys(output).length === 1 &&
|
|
128
|
-
(output as Record<string, unknown>)['~ultimate.waitTimedOut'] === true
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function isStepSuspension(error: unknown): error is StepSuspension {
|
|
133
|
-
return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
123
|
export interface StepRunnerOptions {
|
|
137
124
|
readonly runId: string;
|
|
138
125
|
readonly jobName: string;
|
|
139
126
|
readonly store: StepStore;
|
|
127
|
+
/** The claim this attempt runs under — every write is fenced on it. `executeJob` passes it. */
|
|
128
|
+
readonly fence?: StepFence;
|
|
140
129
|
readonly clock?: Clock;
|
|
141
130
|
readonly events?: EventLookup;
|
|
142
131
|
/** How long a waiting step stays parked between event polls. Default 30s. */
|
|
@@ -149,6 +138,13 @@ export interface StepRunnerOptions {
|
|
|
149
138
|
* store belongs to whichever attempt has it now.
|
|
150
139
|
*/
|
|
151
140
|
readonly signal?: AbortSignal;
|
|
141
|
+
/**
|
|
142
|
+
* Aborted the moment the ATTEMPT is over — settled, or past its job deadline. `signal` alone
|
|
143
|
+
* cannot say so once the drain has aborted it first (a controller keeps its first reason), and
|
|
144
|
+
* the drain's reason writes through `put` (`writesThrough`): this is what still refuses a step
|
|
145
|
+
* left in flight by a body that returned without awaiting it. `executeJob` passes it.
|
|
146
|
+
*/
|
|
147
|
+
readonly ended?: AbortSignal;
|
|
152
148
|
}
|
|
153
149
|
|
|
154
150
|
export interface StepRunner {
|
|
@@ -179,7 +175,7 @@ function trace(into: string[], name: string): void {
|
|
|
179
175
|
if (into.length > MAX_TRACE_NAMES) into.shift();
|
|
180
176
|
}
|
|
181
177
|
|
|
182
|
-
export function
|
|
178
|
+
export function stepRunner(options: StepRunnerOptions): StepRunner {
|
|
183
179
|
const { runId, jobName, store } = options;
|
|
184
180
|
/** Every name this attempt has claimed. Membership only — the trace is `used`. */
|
|
185
181
|
const claimed = new Set<string>();
|
|
@@ -187,6 +183,13 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
187
183
|
const replayed: string[] = [];
|
|
188
184
|
const clock = options.clock;
|
|
189
185
|
const pollMs = finiteOption('step.waitForEvent', 'eventPollMs', options.eventPollMs ?? 30_000);
|
|
186
|
+
// `job()` refuses a non-finite `stepTimeout` at declaration (`job.ts`); screened again here, where
|
|
187
|
+
// it is read, because this runner is also built directly and `withStepTimeout` reads a `NaN`
|
|
188
|
+
// ceiling as none at all — the backstop `pollMs` above already is.
|
|
189
|
+
const stepTimeoutMs =
|
|
190
|
+
options.stepTimeoutMs === undefined
|
|
191
|
+
? undefined
|
|
192
|
+
: finiteOption('step.run', 'stepTimeoutMs', options.stepTimeoutMs);
|
|
190
193
|
const runSignal = options.signal ?? NEVER_ABORTED;
|
|
191
194
|
|
|
192
195
|
/**
|
|
@@ -244,6 +247,19 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
244
247
|
|
|
245
248
|
const cancelled = (): boolean => runSignal.aborted;
|
|
246
249
|
|
|
250
|
+
/**
|
|
251
|
+
* A cancelled run whose step result is still this attempt's to record: the worker's drain cut
|
|
252
|
+
* it short (`X_DRAINING`, `worker-drain-cutoff.ts`), the write is fenced on the claim, and the
|
|
253
|
+
* attempt has not ended. The side effect behind the record HAPPENED — refused, the next worker
|
|
254
|
+
* runs it again. The fence is what makes this safe and not the in-process signal: a row the
|
|
255
|
+
* drain has already handed back answers the write `X_JOB_LEASE_LOST` in the store.
|
|
256
|
+
*/
|
|
257
|
+
const writesThrough = (): boolean =>
|
|
258
|
+
options.fence !== undefined &&
|
|
259
|
+
options.ended?.aborted !== true &&
|
|
260
|
+
isUltimateError(runSignal.reason) &&
|
|
261
|
+
runSignal.reason.code === 'X_DRAINING';
|
|
262
|
+
|
|
247
263
|
/**
|
|
248
264
|
* The statement itself, declared deliberate to the N+1 detector. One write per step IS the
|
|
249
265
|
* design this file's header states — each step completes at its own instant and its output has
|
|
@@ -256,7 +272,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
256
272
|
const persist = (record: StepRecord): Promise<void> =>
|
|
257
273
|
expectedQueryLoop(
|
|
258
274
|
'a durable step is written the instant it completes, one statement per step by design',
|
|
259
|
-
() => store.put(record),
|
|
275
|
+
() => store.put(record, options.fence),
|
|
260
276
|
);
|
|
261
277
|
|
|
262
278
|
/**
|
|
@@ -266,7 +282,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
266
282
|
* never ran — or overwrite one it did. The write is refused, and refusing it unwinds the body.
|
|
267
283
|
*/
|
|
268
284
|
const put = async (record: StepRecord): Promise<void> => {
|
|
269
|
-
if (cancelled()
|
|
285
|
+
if (cancelled() && !writesThrough()) {
|
|
286
|
+
throw new JobAbortedError({ job: jobName, step: record.name });
|
|
287
|
+
}
|
|
270
288
|
await persist(record);
|
|
271
289
|
remember(record);
|
|
272
290
|
};
|
|
@@ -294,15 +312,14 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
294
312
|
// to compose, and DISPOSED in the `finally` below, which is the whole reason `run-signal.ts`
|
|
295
313
|
// exists — `worker-run.ts` disposes the run's own the same way.
|
|
296
314
|
const composed =
|
|
297
|
-
|
|
315
|
+
stepTimeoutMs === undefined ? null : createRunSignal([runSignal, deadline.signal]);
|
|
298
316
|
const signal = composed?.signal ?? runSignal;
|
|
299
317
|
try {
|
|
300
318
|
const output = await withStepTimeout(
|
|
301
319
|
fn(signal),
|
|
302
|
-
|
|
320
|
+
stepTimeoutMs,
|
|
303
321
|
deadline,
|
|
304
|
-
() =>
|
|
305
|
-
new JobTimeoutError({ job: jobName, step: name, timeoutMs: options.stepTimeoutMs ?? 0 }),
|
|
322
|
+
() => new JobTimeoutError({ job: jobName, step: name, timeoutMs: stepTimeoutMs ?? 0 }),
|
|
306
323
|
);
|
|
307
324
|
// Persist BEFORE returning: a crash one line later must not re-run this step.
|
|
308
325
|
await put({
|
|
@@ -331,8 +348,16 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
331
348
|
attempts,
|
|
332
349
|
error: renderThrowable(error),
|
|
333
350
|
};
|
|
334
|
-
|
|
335
|
-
|
|
351
|
+
try {
|
|
352
|
+
await persist(failure);
|
|
353
|
+
remember(failure);
|
|
354
|
+
} catch (unwritten) {
|
|
355
|
+
logger.warn('jobs.step.failure-unrecorded', {
|
|
356
|
+
job: jobName,
|
|
357
|
+
step: name,
|
|
358
|
+
error: renderThrowable(unwritten),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
336
361
|
}
|
|
337
362
|
throw error;
|
|
338
363
|
} finally {
|
|
@@ -389,7 +414,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
389
414
|
}
|
|
390
415
|
|
|
391
416
|
const at = now();
|
|
392
|
-
|
|
417
|
+
// A NEW wait is stamped by the bus, whose clock stamps every `publishedAt` it is compared
|
|
418
|
+
// with; the runner's own only when there is no bus to ask. A re-poll keeps what it persisted.
|
|
419
|
+
const startedAt = existing?.startedAt ?? (await options.events?.now()) ?? at;
|
|
393
420
|
const deadline =
|
|
394
421
|
startedAt +
|
|
395
422
|
finiteDurationMs(waitOptions.timeout ?? 86_400_000, 'step.waitForEvent', 'timeout');
|
|
@@ -411,7 +438,13 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
411
438
|
return hit.payload as T;
|
|
412
439
|
}
|
|
413
440
|
|
|
414
|
-
|
|
441
|
+
// `deadline` is on the BUS's clock, so the bus calls the timeout: asked only once this runner
|
|
442
|
+
// believes the time is up, which costs a wait one statement at its end. A runner AHEAD of the
|
|
443
|
+
// bus used to give up early by the skew — on an answer still inside its window. (A runner
|
|
444
|
+
// BEHIND waits the skew longer; knowing that sooner would cost a round trip on every poll.)
|
|
445
|
+
let remaining = deadline - at;
|
|
446
|
+
if (remaining <= 0) remaining = deadline - ((await options.events?.now()) ?? at);
|
|
447
|
+
if (remaining <= 0) {
|
|
415
448
|
if (waitOptions.required === true) {
|
|
416
449
|
throw new JobTimeoutError({
|
|
417
450
|
job: jobName,
|
|
@@ -437,7 +470,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
437
470
|
return undefined;
|
|
438
471
|
}
|
|
439
472
|
|
|
440
|
-
const resumeAt = Math.min(
|
|
473
|
+
const resumeAt = at + Math.min(remaining, pollMs);
|
|
441
474
|
await put({
|
|
442
475
|
runId,
|
|
443
476
|
name,
|
|
@@ -463,34 +496,3 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
463
496
|
replayedNames: () => [...replayed],
|
|
464
497
|
};
|
|
465
498
|
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* A step's own ceiling. It ABORTS before it rejects, in that order: `run()` fails the step on this
|
|
469
|
-
* rejection and the job retries, so a body still holding a socket open past the deadline would be
|
|
470
|
-
* racing the attempt that replaced it. Cancelling first is the only thing that can stop it.
|
|
471
|
-
*/
|
|
472
|
-
function withStepTimeout<T>(
|
|
473
|
-
work: Promise<T> | T,
|
|
474
|
-
timeoutMs: number | undefined,
|
|
475
|
-
deadline: AbortController,
|
|
476
|
-
error: () => Error,
|
|
477
|
-
): Promise<T> {
|
|
478
|
-
if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
|
|
479
|
-
return new Promise<T>((resolve, reject) => {
|
|
480
|
-
const timer = setTimeout(() => {
|
|
481
|
-
const failure = error();
|
|
482
|
-
deadline.abort(failure);
|
|
483
|
-
reject(failure);
|
|
484
|
-
}, timeoutMs);
|
|
485
|
-
Promise.resolve(work).then(
|
|
486
|
-
(value) => {
|
|
487
|
-
clearTimeout(timer);
|
|
488
|
-
resolve(value);
|
|
489
|
-
},
|
|
490
|
-
(cause) => {
|
|
491
|
-
clearTimeout(timer);
|
|
492
|
-
reject(cause);
|
|
493
|
-
},
|
|
494
|
-
);
|
|
495
|
-
});
|
|
496
|
-
}
|
package/src/task.ts
CHANGED
|
@@ -141,7 +141,7 @@ export function task(definition: TaskDefinition): TaskHandle {
|
|
|
141
141
|
// `for (let i = 0; i < handle.maxCatchUp; i += 1)`, so zero (and any negative, and any fraction
|
|
142
142
|
// below one) returns an empty list on every round and the task NEVER fires: no error, no log
|
|
143
143
|
// line, no queue row, forever. Refused where it is written, exactly as `job()` refuses
|
|
144
|
-
// `concurrency: 0` and `
|
|
144
|
+
// `concurrency: 0` and `backfillPacer` refuses `rate: 0`. `Number.isInteger` covers `NaN` and
|
|
145
145
|
// `Infinity` in the same predicate — an unbounded catch-up is a burst nobody declared.
|
|
146
146
|
assert(
|
|
147
147
|
definition.maxCatchUp === undefined ||
|