@ultimat3/jobs 1.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/LICENSE +21 -0
- package/README.md +202 -0
- package/package.json +38 -0
- package/src/clock.d.ts +7 -0
- package/src/clock.d.ts.map +1 -0
- package/src/clock.js +21 -0
- package/src/clock.js.map +1 -0
- package/src/clock.ts +23 -0
- package/src/describe.ts +61 -0
- package/src/driver-memory.d.ts +9 -0
- package/src/driver-memory.d.ts.map +1 -0
- package/src/driver-memory.js +189 -0
- package/src/driver-memory.js.map +1 -0
- package/src/driver-memory.ts +216 -0
- package/src/driver-nats.d.ts +7 -0
- package/src/driver-nats.d.ts.map +1 -0
- package/src/driver-nats.js +51 -0
- package/src/driver-nats.js.map +1 -0
- package/src/driver-nats.ts +73 -0
- package/src/driver-pg-sql.d.ts +19 -0
- package/src/driver-pg-sql.d.ts.map +1 -0
- package/src/driver-pg-sql.js +160 -0
- package/src/driver-pg-sql.js.map +1 -0
- package/src/driver-pg-sql.ts +170 -0
- package/src/driver-pg.d.ts +17 -0
- package/src/driver-pg.d.ts.map +1 -0
- package/src/driver-pg.js +246 -0
- package/src/driver-pg.js.map +1 -0
- package/src/driver-pg.ts +356 -0
- package/src/driver-redis.d.ts +7 -0
- package/src/driver-redis.d.ts.map +1 -0
- package/src/driver-redis.js +54 -0
- package/src/driver-redis.js.map +1 -0
- package/src/driver-redis.ts +76 -0
- package/src/driver.d.ts +114 -0
- package/src/driver.d.ts.map +1 -0
- package/src/driver.js +14 -0
- package/src/driver.js.map +1 -0
- package/src/driver.ts +143 -0
- package/src/errors.d.ts +63 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +105 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +165 -0
- package/src/events.d.ts +35 -0
- package/src/events.d.ts.map +1 -0
- package/src/events.js +92 -0
- package/src/events.js.map +1 -0
- package/src/events.ts +134 -0
- package/src/index.d.ts +32 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +172 -0
- package/src/inspect.d.ts +82 -0
- package/src/inspect.d.ts.map +1 -0
- package/src/inspect.js +113 -0
- package/src/inspect.js.map +1 -0
- package/src/inspect.ts +213 -0
- package/src/job.d.ts +71 -0
- package/src/job.d.ts.map +1 -0
- package/src/job.js +99 -0
- package/src/job.js.map +1 -0
- package/src/job.ts +261 -0
- package/src/limits.d.ts +47 -0
- package/src/limits.d.ts.map +1 -0
- package/src/limits.js +0 -0
- package/src/limits.js.map +1 -0
- package/src/limits.ts +0 -0
- package/src/outbox.d.ts +81 -0
- package/src/outbox.d.ts.map +1 -0
- package/src/outbox.js +202 -0
- package/src/outbox.js.map +1 -0
- package/src/outbox.ts +336 -0
- package/src/register.ts +40 -0
- package/src/retry.d.ts +40 -0
- package/src/retry.d.ts.map +1 -0
- package/src/retry.js +59 -0
- package/src/retry.js.map +1 -0
- package/src/retry.ts +90 -0
- package/src/scheduler.d.ts +79 -0
- package/src/scheduler.d.ts.map +1 -0
- package/src/scheduler.js +183 -0
- package/src/scheduler.js.map +1 -0
- package/src/scheduler.ts +417 -0
- package/src/steps.d.ts +86 -0
- package/src/steps.d.ts.map +1 -0
- package/src/steps.js +227 -0
- package/src/steps.js.map +1 -0
- package/src/steps.ts +339 -0
- package/src/worker.d.ts +68 -0
- package/src/worker.d.ts.map +1 -0
- package/src/worker.js +273 -0
- package/src/worker.js.map +1 -0
- package/src/worker.ts +356 -0
package/src/steps.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Clock } from '@ultimat3/core';
|
|
2
|
+
import type { DurationInput } from './clock';
|
|
3
|
+
export type StepStatus = 'completed' | 'sleeping' | 'waiting' | 'failed';
|
|
4
|
+
export interface StepRecord {
|
|
5
|
+
readonly runId: string;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly status: StepStatus;
|
|
8
|
+
/** The memoized return value. Present only when `status === 'completed'`. */
|
|
9
|
+
readonly output?: unknown;
|
|
10
|
+
readonly startedAt: number;
|
|
11
|
+
readonly completedAt?: number;
|
|
12
|
+
/** Epoch ms at which a sleeping/waiting step becomes runnable. */
|
|
13
|
+
readonly wakeAt?: number;
|
|
14
|
+
readonly event?: string;
|
|
15
|
+
readonly correlationKey?: string;
|
|
16
|
+
readonly attempts: number;
|
|
17
|
+
readonly error?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface StepStore {
|
|
20
|
+
get(runId: string, name: string): Promise<StepRecord | undefined>;
|
|
21
|
+
put(record: StepRecord): Promise<void>;
|
|
22
|
+
list(runId: string): Promise<readonly StepRecord[]>;
|
|
23
|
+
del(runId: string, name: string): Promise<void>;
|
|
24
|
+
clear(runId: string): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** The slice of the event bus a waiting step needs. Implemented by `events.ts`. */
|
|
27
|
+
export interface EventLookup {
|
|
28
|
+
find(event: string, correlationKey: string | undefined, afterMs: number): Promise<{
|
|
29
|
+
readonly payload: unknown;
|
|
30
|
+
readonly publishedAt: number;
|
|
31
|
+
} | undefined>;
|
|
32
|
+
}
|
|
33
|
+
export interface WaitForEventOptions {
|
|
34
|
+
/** How long to wait before giving up. Default 24h. */
|
|
35
|
+
readonly timeout?: DurationInput;
|
|
36
|
+
/** Correlation key the published event must carry — usually an entity id. */
|
|
37
|
+
readonly match?: string;
|
|
38
|
+
/** Default false: a timeout resolves `undefined`. `true` fails the job with X_JOB_TIMEOUT. */
|
|
39
|
+
readonly required?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface StepApi {
|
|
42
|
+
/** Run once, ever. On replay the persisted output is returned and `fn` is not called. */
|
|
43
|
+
run<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
|
|
44
|
+
/** Suspend the run. `sleep(duration)` derives the step name from the duration. */
|
|
45
|
+
sleep(name: string, duration: DurationInput): Promise<void>;
|
|
46
|
+
sleep(duration: DurationInput): Promise<void>;
|
|
47
|
+
waitForEvent<T>(name: string, event: string, options?: WaitForEventOptions): Promise<T | undefined>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Control flow, not a failure: it must never be logged as an error or counted as an attempt.
|
|
51
|
+
* Branded by string rather than `instanceof` so two copies of this module still agree.
|
|
52
|
+
*/
|
|
53
|
+
export declare class StepSuspension extends Error {
|
|
54
|
+
static readonly brand = "ultimate.jobs.suspension";
|
|
55
|
+
readonly brand: string;
|
|
56
|
+
readonly step: string;
|
|
57
|
+
readonly resumeAt: number;
|
|
58
|
+
readonly reason: 'sleep' | 'event';
|
|
59
|
+
constructor(input: {
|
|
60
|
+
step: string;
|
|
61
|
+
resumeAt: number;
|
|
62
|
+
reason: 'sleep' | 'event';
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export declare function isStepSuspension(error: unknown): error is StepSuspension;
|
|
66
|
+
export declare function createMemoryStepStore(): StepStore;
|
|
67
|
+
export interface StepRunnerOptions {
|
|
68
|
+
readonly runId: string;
|
|
69
|
+
readonly jobName: string;
|
|
70
|
+
readonly store: StepStore;
|
|
71
|
+
readonly clock?: Clock;
|
|
72
|
+
readonly events?: EventLookup;
|
|
73
|
+
/** How long a waiting step stays parked between event polls. Default 30s. */
|
|
74
|
+
readonly eventPollMs?: number;
|
|
75
|
+
/** Per-step ceiling; the job-level timeout is enforced by the worker. */
|
|
76
|
+
readonly stepTimeoutMs?: number;
|
|
77
|
+
}
|
|
78
|
+
export interface StepRunner {
|
|
79
|
+
readonly step: StepApi;
|
|
80
|
+
/** Names used in THIS attempt, in order — the trace shown by `x jobs show`. */
|
|
81
|
+
usedNames(): readonly string[];
|
|
82
|
+
/** Names that were served from storage instead of executed. */
|
|
83
|
+
replayedNames(): readonly string[];
|
|
84
|
+
}
|
|
85
|
+
export declare function createStepRunner(options: StepRunnerOptions): StepRunner;
|
|
86
|
+
//# sourceMappingURL=steps.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"steps.d.ts","sourceRoot":"","sources":["steps.ts"],"names":[],"mappings":"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"}
|
package/src/steps.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Durable steps — the heart of the package. Each step's result is PERSISTED before the next
|
|
2
|
+
// one starts, so a retry replays completed steps from storage instead of re-executing them:
|
|
3
|
+
// the welcome email is not sent twice because step 3 failed. That makes step names the replay
|
|
4
|
+
// key, which is why they must be deterministic and unique within a run (X_STEP_DUPLICATE).
|
|
5
|
+
//
|
|
6
|
+
// Suspension (sleep / waitForEvent) unwinds the run by throwing a StepSuspension. The worker
|
|
7
|
+
// catches it and re-queues the job for `resumeAt` instead of holding a process for three days.
|
|
8
|
+
import { logger } from '@ultimat3/core';
|
|
9
|
+
import { nowMs, toMs } from './clock';
|
|
10
|
+
import { JobTimeoutError, StepDuplicateError } from './errors';
|
|
11
|
+
/**
|
|
12
|
+
* Control flow, not a failure: it must never be logged as an error or counted as an attempt.
|
|
13
|
+
* Branded by string rather than `instanceof` so two copies of this module still agree.
|
|
14
|
+
*/
|
|
15
|
+
export class StepSuspension extends Error {
|
|
16
|
+
static brand = 'ultimate.jobs.suspension';
|
|
17
|
+
brand = StepSuspension.brand;
|
|
18
|
+
step;
|
|
19
|
+
resumeAt;
|
|
20
|
+
reason;
|
|
21
|
+
constructor(input) {
|
|
22
|
+
super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
|
|
23
|
+
this.name = 'StepSuspension';
|
|
24
|
+
this.step = input.step;
|
|
25
|
+
this.resumeAt = input.resumeAt;
|
|
26
|
+
this.reason = input.reason;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function isStepSuspension(error) {
|
|
30
|
+
return error instanceof Error && error.brand === StepSuspension.brand;
|
|
31
|
+
}
|
|
32
|
+
export function createMemoryStepStore() {
|
|
33
|
+
const byRun = new Map();
|
|
34
|
+
const runOf = (runId) => {
|
|
35
|
+
let run = byRun.get(runId);
|
|
36
|
+
if (run === undefined) {
|
|
37
|
+
run = new Map();
|
|
38
|
+
byRun.set(runId, run);
|
|
39
|
+
}
|
|
40
|
+
return run;
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
get(runId, name) {
|
|
44
|
+
return Promise.resolve(byRun.get(runId)?.get(name));
|
|
45
|
+
},
|
|
46
|
+
put(record) {
|
|
47
|
+
runOf(record.runId).set(record.name, record);
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
},
|
|
50
|
+
list(runId) {
|
|
51
|
+
const records = [...(byRun.get(runId)?.values() ?? [])];
|
|
52
|
+
return Promise.resolve(records.sort((a, b) => a.startedAt - b.startedAt));
|
|
53
|
+
},
|
|
54
|
+
del(runId, name) {
|
|
55
|
+
byRun.get(runId)?.delete(name);
|
|
56
|
+
return Promise.resolve();
|
|
57
|
+
},
|
|
58
|
+
clear(runId) {
|
|
59
|
+
byRun.delete(runId);
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function createStepRunner(options) {
|
|
65
|
+
const { runId, jobName, store } = options;
|
|
66
|
+
const used = [];
|
|
67
|
+
const replayed = [];
|
|
68
|
+
const clock = options.clock;
|
|
69
|
+
const pollMs = options.eventPollMs ?? 30_000;
|
|
70
|
+
const claimName = (name) => {
|
|
71
|
+
if (used.includes(name))
|
|
72
|
+
throw new StepDuplicateError({ job: jobName, step: name });
|
|
73
|
+
used.push(name);
|
|
74
|
+
};
|
|
75
|
+
const now = () => nowMs(clock);
|
|
76
|
+
async function run(name, fn) {
|
|
77
|
+
claimName(name);
|
|
78
|
+
const existing = await store.get(runId, name);
|
|
79
|
+
if (existing?.status === 'completed') {
|
|
80
|
+
replayed.push(name);
|
|
81
|
+
return existing.output;
|
|
82
|
+
}
|
|
83
|
+
const startedAt = existing?.startedAt ?? now();
|
|
84
|
+
const attempts = (existing?.attempts ?? 0) + 1;
|
|
85
|
+
try {
|
|
86
|
+
const output = await withStepTimeout(fn(), options.stepTimeoutMs, () => new JobTimeoutError({ job: jobName, step: name, timeoutMs: options.stepTimeoutMs ?? 0 }));
|
|
87
|
+
// Persist BEFORE returning: a crash one line later must not re-run this step.
|
|
88
|
+
await store.put({
|
|
89
|
+
runId,
|
|
90
|
+
name,
|
|
91
|
+
status: 'completed',
|
|
92
|
+
output,
|
|
93
|
+
startedAt,
|
|
94
|
+
completedAt: now(),
|
|
95
|
+
attempts,
|
|
96
|
+
});
|
|
97
|
+
return output;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (isStepSuspension(error))
|
|
101
|
+
throw error;
|
|
102
|
+
await store.put({
|
|
103
|
+
runId,
|
|
104
|
+
name,
|
|
105
|
+
status: 'failed',
|
|
106
|
+
startedAt,
|
|
107
|
+
attempts,
|
|
108
|
+
error: error instanceof Error ? error.message : String(error),
|
|
109
|
+
});
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function sleep(a, b) {
|
|
114
|
+
// `sleep('3d')` — the duration doubles as the step name, which stays deterministic.
|
|
115
|
+
const name = b === undefined ? `sleep:${String(a)}` : String(a);
|
|
116
|
+
const duration = b === undefined ? a : b;
|
|
117
|
+
claimName(name);
|
|
118
|
+
const existing = await store.get(runId, name);
|
|
119
|
+
if (existing?.status === 'completed') {
|
|
120
|
+
replayed.push(name);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const at = now();
|
|
124
|
+
if (existing?.status === 'sleeping' && existing.wakeAt !== undefined) {
|
|
125
|
+
if (existing.wakeAt <= at) {
|
|
126
|
+
await store.put({ ...existing, status: 'completed', completedAt: at });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
throw new StepSuspension({ step: name, resumeAt: existing.wakeAt, reason: 'sleep' });
|
|
130
|
+
}
|
|
131
|
+
const wakeAt = at + toMs(duration);
|
|
132
|
+
await store.put({
|
|
133
|
+
runId,
|
|
134
|
+
name,
|
|
135
|
+
status: 'sleeping',
|
|
136
|
+
startedAt: at,
|
|
137
|
+
wakeAt,
|
|
138
|
+
attempts: 1,
|
|
139
|
+
});
|
|
140
|
+
throw new StepSuspension({ step: name, resumeAt: wakeAt, reason: 'sleep' });
|
|
141
|
+
}
|
|
142
|
+
async function waitForEvent(name, event, waitOptions = {}) {
|
|
143
|
+
claimName(name);
|
|
144
|
+
const existing = await store.get(runId, name);
|
|
145
|
+
if (existing?.status === 'completed') {
|
|
146
|
+
replayed.push(name);
|
|
147
|
+
return existing.output;
|
|
148
|
+
}
|
|
149
|
+
const at = now();
|
|
150
|
+
const startedAt = existing?.startedAt ?? at;
|
|
151
|
+
const deadline = startedAt + toMs(waitOptions.timeout ?? 86_400_000);
|
|
152
|
+
const correlationKey = waitOptions.match;
|
|
153
|
+
const hit = await options.events?.find(event, correlationKey, startedAt);
|
|
154
|
+
if (hit !== undefined) {
|
|
155
|
+
await store.put({
|
|
156
|
+
runId,
|
|
157
|
+
name,
|
|
158
|
+
status: 'completed',
|
|
159
|
+
output: hit.payload,
|
|
160
|
+
startedAt,
|
|
161
|
+
completedAt: at,
|
|
162
|
+
event,
|
|
163
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
164
|
+
...(correlationKey === undefined ? {} : { correlationKey }),
|
|
165
|
+
});
|
|
166
|
+
return hit.payload;
|
|
167
|
+
}
|
|
168
|
+
if (at >= deadline) {
|
|
169
|
+
if (waitOptions.required === true) {
|
|
170
|
+
throw new JobTimeoutError({
|
|
171
|
+
job: jobName,
|
|
172
|
+
step: name,
|
|
173
|
+
timeoutMs: toMs(waitOptions.timeout ?? 86_400_000),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
logger.warn('jobs.step.wait-timeout', { job: jobName, step: name, event });
|
|
177
|
+
await store.put({
|
|
178
|
+
runId,
|
|
179
|
+
name,
|
|
180
|
+
status: 'completed',
|
|
181
|
+
output: undefined,
|
|
182
|
+
startedAt,
|
|
183
|
+
completedAt: at,
|
|
184
|
+
event,
|
|
185
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
186
|
+
});
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const resumeAt = Math.min(deadline, at + pollMs);
|
|
190
|
+
await store.put({
|
|
191
|
+
runId,
|
|
192
|
+
name,
|
|
193
|
+
status: 'waiting',
|
|
194
|
+
startedAt,
|
|
195
|
+
wakeAt: resumeAt,
|
|
196
|
+
event,
|
|
197
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
198
|
+
...(correlationKey === undefined ? {} : { correlationKey }),
|
|
199
|
+
});
|
|
200
|
+
throw new StepSuspension({ step: name, resumeAt, reason: 'event' });
|
|
201
|
+
}
|
|
202
|
+
const step = {
|
|
203
|
+
run,
|
|
204
|
+
sleep: sleep,
|
|
205
|
+
waitForEvent,
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
step,
|
|
209
|
+
usedNames: () => [...used],
|
|
210
|
+
replayedNames: () => [...replayed],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function withStepTimeout(work, timeoutMs, error) {
|
|
214
|
+
if (timeoutMs === undefined || timeoutMs <= 0)
|
|
215
|
+
return Promise.resolve(work);
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
const timer = setTimeout(() => reject(error()), timeoutMs);
|
|
218
|
+
Promise.resolve(work).then((value) => {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
resolve(value);
|
|
221
|
+
}, (cause) => {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
reject(cause);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=steps.js.map
|
package/src/steps.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"steps.js","sourceRoot":"","sources":["steps.ts"],"names":[],"mappings":"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"}
|
package/src/steps.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Durable steps — the heart of the package. Each step's result is PERSISTED before the next
|
|
2
|
+
// one starts, so a retry replays completed steps from storage instead of re-executing them:
|
|
3
|
+
// the welcome email is not sent twice because step 3 failed. That makes step names the replay
|
|
4
|
+
// key, which is why they must be deterministic and unique within a run (X_STEP_DUPLICATE).
|
|
5
|
+
//
|
|
6
|
+
// Suspension (sleep / waitForEvent) unwinds the run by throwing a StepSuspension. The worker
|
|
7
|
+
// catches it and re-queues the job for `resumeAt` instead of holding a process for three days.
|
|
8
|
+
|
|
9
|
+
import type { Clock } from '@ultimat3/core';
|
|
10
|
+
import { logger } from '@ultimat3/core';
|
|
11
|
+
import type { DurationInput } from './clock';
|
|
12
|
+
import { nowMs, toMs } from './clock';
|
|
13
|
+
import { JobTimeoutError, StepDuplicateError } from './errors';
|
|
14
|
+
|
|
15
|
+
export type StepStatus = 'completed' | 'sleeping' | 'waiting' | 'failed';
|
|
16
|
+
|
|
17
|
+
export interface StepRecord {
|
|
18
|
+
readonly runId: string;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly status: StepStatus;
|
|
21
|
+
/** The memoized return value. Present only when `status === 'completed'`. */
|
|
22
|
+
readonly output?: unknown;
|
|
23
|
+
readonly startedAt: number;
|
|
24
|
+
readonly completedAt?: number;
|
|
25
|
+
/** Epoch ms at which a sleeping/waiting step becomes runnable. */
|
|
26
|
+
readonly wakeAt?: number;
|
|
27
|
+
readonly event?: string;
|
|
28
|
+
readonly correlationKey?: string;
|
|
29
|
+
readonly attempts: number;
|
|
30
|
+
readonly error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StepStore {
|
|
34
|
+
get(runId: string, name: string): Promise<StepRecord | undefined>;
|
|
35
|
+
put(record: StepRecord): Promise<void>;
|
|
36
|
+
list(runId: string): Promise<readonly StepRecord[]>;
|
|
37
|
+
del(runId: string, name: string): Promise<void>;
|
|
38
|
+
clear(runId: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The slice of the event bus a waiting step needs. Implemented by `events.ts`. */
|
|
42
|
+
export interface EventLookup {
|
|
43
|
+
find(
|
|
44
|
+
event: string,
|
|
45
|
+
correlationKey: string | undefined,
|
|
46
|
+
afterMs: number,
|
|
47
|
+
): Promise<{ readonly payload: unknown; readonly publishedAt: number } | undefined>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WaitForEventOptions {
|
|
51
|
+
/** How long to wait before giving up. Default 24h. */
|
|
52
|
+
readonly timeout?: DurationInput;
|
|
53
|
+
/** Correlation key the published event must carry — usually an entity id. */
|
|
54
|
+
readonly match?: string;
|
|
55
|
+
/** Default false: a timeout resolves `undefined`. `true` fails the job with X_JOB_TIMEOUT. */
|
|
56
|
+
readonly required?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StepApi {
|
|
60
|
+
/** Run once, ever. On replay the persisted output is returned and `fn` is not called. */
|
|
61
|
+
run<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
|
|
62
|
+
/** Suspend the run. `sleep(duration)` derives the step name from the duration. */
|
|
63
|
+
sleep(name: string, duration: DurationInput): Promise<void>;
|
|
64
|
+
sleep(duration: DurationInput): Promise<void>;
|
|
65
|
+
waitForEvent<T>(
|
|
66
|
+
name: string,
|
|
67
|
+
event: string,
|
|
68
|
+
options?: WaitForEventOptions,
|
|
69
|
+
): Promise<T | undefined>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Control flow, not a failure: it must never be logged as an error or counted as an attempt.
|
|
74
|
+
* Branded by string rather than `instanceof` so two copies of this module still agree.
|
|
75
|
+
*/
|
|
76
|
+
export class StepSuspension extends Error {
|
|
77
|
+
static readonly brand = 'ultimate.jobs.suspension';
|
|
78
|
+
readonly brand: string = StepSuspension.brand;
|
|
79
|
+
readonly step: string;
|
|
80
|
+
readonly resumeAt: number;
|
|
81
|
+
readonly reason: 'sleep' | 'event';
|
|
82
|
+
|
|
83
|
+
constructor(input: { step: string; resumeAt: number; reason: 'sleep' | 'event' }) {
|
|
84
|
+
super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
|
|
85
|
+
this.name = 'StepSuspension';
|
|
86
|
+
this.step = input.step;
|
|
87
|
+
this.resumeAt = input.resumeAt;
|
|
88
|
+
this.reason = input.reason;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isStepSuspension(error: unknown): error is StepSuspension {
|
|
93
|
+
return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createMemoryStepStore(): StepStore {
|
|
97
|
+
const byRun = new Map<string, Map<string, StepRecord>>();
|
|
98
|
+
const runOf = (runId: string): Map<string, StepRecord> => {
|
|
99
|
+
let run = byRun.get(runId);
|
|
100
|
+
if (run === undefined) {
|
|
101
|
+
run = new Map();
|
|
102
|
+
byRun.set(runId, run);
|
|
103
|
+
}
|
|
104
|
+
return run;
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
get(runId, name) {
|
|
108
|
+
return Promise.resolve(byRun.get(runId)?.get(name));
|
|
109
|
+
},
|
|
110
|
+
put(record) {
|
|
111
|
+
runOf(record.runId).set(record.name, record);
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
},
|
|
114
|
+
list(runId) {
|
|
115
|
+
const records = [...(byRun.get(runId)?.values() ?? [])];
|
|
116
|
+
return Promise.resolve(records.sort((a, b) => a.startedAt - b.startedAt));
|
|
117
|
+
},
|
|
118
|
+
del(runId, name) {
|
|
119
|
+
byRun.get(runId)?.delete(name);
|
|
120
|
+
return Promise.resolve();
|
|
121
|
+
},
|
|
122
|
+
clear(runId) {
|
|
123
|
+
byRun.delete(runId);
|
|
124
|
+
return Promise.resolve();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface StepRunnerOptions {
|
|
130
|
+
readonly runId: string;
|
|
131
|
+
readonly jobName: string;
|
|
132
|
+
readonly store: StepStore;
|
|
133
|
+
readonly clock?: Clock;
|
|
134
|
+
readonly events?: EventLookup;
|
|
135
|
+
/** How long a waiting step stays parked between event polls. Default 30s. */
|
|
136
|
+
readonly eventPollMs?: number;
|
|
137
|
+
/** Per-step ceiling; the job-level timeout is enforced by the worker. */
|
|
138
|
+
readonly stepTimeoutMs?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface StepRunner {
|
|
142
|
+
readonly step: StepApi;
|
|
143
|
+
/** Names used in THIS attempt, in order — the trace shown by `x jobs show`. */
|
|
144
|
+
usedNames(): readonly string[];
|
|
145
|
+
/** Names that were served from storage instead of executed. */
|
|
146
|
+
replayedNames(): readonly string[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
150
|
+
const { runId, jobName, store } = options;
|
|
151
|
+
const used: string[] = [];
|
|
152
|
+
const replayed: string[] = [];
|
|
153
|
+
const clock = options.clock;
|
|
154
|
+
const pollMs = options.eventPollMs ?? 30_000;
|
|
155
|
+
|
|
156
|
+
const claimName = (name: string): void => {
|
|
157
|
+
if (used.includes(name)) throw new StepDuplicateError({ job: jobName, step: name });
|
|
158
|
+
used.push(name);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const now = (): number => nowMs(clock);
|
|
162
|
+
|
|
163
|
+
async function run<T>(name: string, fn: () => Promise<T> | T): Promise<T> {
|
|
164
|
+
claimName(name);
|
|
165
|
+
const existing = await store.get(runId, name);
|
|
166
|
+
if (existing?.status === 'completed') {
|
|
167
|
+
replayed.push(name);
|
|
168
|
+
return existing.output as T;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const startedAt = existing?.startedAt ?? now();
|
|
172
|
+
const attempts = (existing?.attempts ?? 0) + 1;
|
|
173
|
+
try {
|
|
174
|
+
const output = await withStepTimeout(
|
|
175
|
+
fn(),
|
|
176
|
+
options.stepTimeoutMs,
|
|
177
|
+
() =>
|
|
178
|
+
new JobTimeoutError({ job: jobName, step: name, timeoutMs: options.stepTimeoutMs ?? 0 }),
|
|
179
|
+
);
|
|
180
|
+
// Persist BEFORE returning: a crash one line later must not re-run this step.
|
|
181
|
+
await store.put({
|
|
182
|
+
runId,
|
|
183
|
+
name,
|
|
184
|
+
status: 'completed',
|
|
185
|
+
output,
|
|
186
|
+
startedAt,
|
|
187
|
+
completedAt: now(),
|
|
188
|
+
attempts,
|
|
189
|
+
});
|
|
190
|
+
return output;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (isStepSuspension(error)) throw error;
|
|
193
|
+
await store.put({
|
|
194
|
+
runId,
|
|
195
|
+
name,
|
|
196
|
+
status: 'failed',
|
|
197
|
+
startedAt,
|
|
198
|
+
attempts,
|
|
199
|
+
error: error instanceof Error ? error.message : String(error),
|
|
200
|
+
});
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function sleep(a: string | DurationInput, b?: DurationInput): Promise<void> {
|
|
206
|
+
// `sleep('3d')` — the duration doubles as the step name, which stays deterministic.
|
|
207
|
+
const name = b === undefined ? `sleep:${String(a)}` : String(a);
|
|
208
|
+
const duration = b === undefined ? (a as DurationInput) : b;
|
|
209
|
+
claimName(name);
|
|
210
|
+
|
|
211
|
+
const existing = await store.get(runId, name);
|
|
212
|
+
if (existing?.status === 'completed') {
|
|
213
|
+
replayed.push(name);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const at = now();
|
|
218
|
+
if (existing?.status === 'sleeping' && existing.wakeAt !== undefined) {
|
|
219
|
+
if (existing.wakeAt <= at) {
|
|
220
|
+
await store.put({ ...existing, status: 'completed', completedAt: at });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
throw new StepSuspension({ step: name, resumeAt: existing.wakeAt, reason: 'sleep' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const wakeAt = at + toMs(duration);
|
|
227
|
+
await store.put({
|
|
228
|
+
runId,
|
|
229
|
+
name,
|
|
230
|
+
status: 'sleeping',
|
|
231
|
+
startedAt: at,
|
|
232
|
+
wakeAt,
|
|
233
|
+
attempts: 1,
|
|
234
|
+
});
|
|
235
|
+
throw new StepSuspension({ step: name, resumeAt: wakeAt, reason: 'sleep' });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function waitForEvent<T>(
|
|
239
|
+
name: string,
|
|
240
|
+
event: string,
|
|
241
|
+
waitOptions: WaitForEventOptions = {},
|
|
242
|
+
): Promise<T | undefined> {
|
|
243
|
+
claimName(name);
|
|
244
|
+
const existing = await store.get(runId, name);
|
|
245
|
+
if (existing?.status === 'completed') {
|
|
246
|
+
replayed.push(name);
|
|
247
|
+
return existing.output as T | undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const at = now();
|
|
251
|
+
const startedAt = existing?.startedAt ?? at;
|
|
252
|
+
const deadline = startedAt + toMs(waitOptions.timeout ?? 86_400_000);
|
|
253
|
+
const correlationKey = waitOptions.match;
|
|
254
|
+
|
|
255
|
+
const hit = await options.events?.find(event, correlationKey, startedAt);
|
|
256
|
+
if (hit !== undefined) {
|
|
257
|
+
await store.put({
|
|
258
|
+
runId,
|
|
259
|
+
name,
|
|
260
|
+
status: 'completed',
|
|
261
|
+
output: hit.payload,
|
|
262
|
+
startedAt,
|
|
263
|
+
completedAt: at,
|
|
264
|
+
event,
|
|
265
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
266
|
+
...(correlationKey === undefined ? {} : { correlationKey }),
|
|
267
|
+
});
|
|
268
|
+
return hit.payload as T;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (at >= deadline) {
|
|
272
|
+
if (waitOptions.required === true) {
|
|
273
|
+
throw new JobTimeoutError({
|
|
274
|
+
job: jobName,
|
|
275
|
+
step: name,
|
|
276
|
+
timeoutMs: toMs(waitOptions.timeout ?? 86_400_000),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
logger.warn('jobs.step.wait-timeout', { job: jobName, step: name, event });
|
|
280
|
+
await store.put({
|
|
281
|
+
runId,
|
|
282
|
+
name,
|
|
283
|
+
status: 'completed',
|
|
284
|
+
output: undefined,
|
|
285
|
+
startedAt,
|
|
286
|
+
completedAt: at,
|
|
287
|
+
event,
|
|
288
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
289
|
+
});
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const resumeAt = Math.min(deadline, at + pollMs);
|
|
294
|
+
await store.put({
|
|
295
|
+
runId,
|
|
296
|
+
name,
|
|
297
|
+
status: 'waiting',
|
|
298
|
+
startedAt,
|
|
299
|
+
wakeAt: resumeAt,
|
|
300
|
+
event,
|
|
301
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
302
|
+
...(correlationKey === undefined ? {} : { correlationKey }),
|
|
303
|
+
});
|
|
304
|
+
throw new StepSuspension({ step: name, resumeAt, reason: 'event' });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const step: StepApi = {
|
|
308
|
+
run,
|
|
309
|
+
sleep: sleep as StepApi['sleep'],
|
|
310
|
+
waitForEvent,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
step,
|
|
315
|
+
usedNames: () => [...used],
|
|
316
|
+
replayedNames: () => [...replayed],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function withStepTimeout<T>(
|
|
321
|
+
work: Promise<T> | T,
|
|
322
|
+
timeoutMs: number | undefined,
|
|
323
|
+
error: () => Error,
|
|
324
|
+
): Promise<T> {
|
|
325
|
+
if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
|
|
326
|
+
return new Promise<T>((resolve, reject) => {
|
|
327
|
+
const timer = setTimeout(() => reject(error()), timeoutMs);
|
|
328
|
+
Promise.resolve(work).then(
|
|
329
|
+
(value) => {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
resolve(value);
|
|
332
|
+
},
|
|
333
|
+
(cause) => {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
reject(cause);
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
}
|