@crewhaus/durable-execution 0.3.1 → 0.4.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/dist/idempotency-store.d.ts +22 -0
- package/dist/idempotency-store.js +84 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +36 -0
- package/dist/schedule.d.ts +73 -0
- package/dist/schedule.js +238 -0
- package/package.json +3 -3
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type IdempotencyKey, type IdempotencyRecord, type IdempotencyStore } from "./index";
|
|
2
|
+
/**
|
|
3
|
+
* A filesystem-backed idempotency store: one `<key>.json` per record under
|
|
4
|
+
* `dir`. `get` fails safe (a missing/corrupt file reads as "no record", so a
|
|
5
|
+
* torn write re-runs rather than throwing); `put` is a single atomic-enough
|
|
6
|
+
* `writeFileSync`.
|
|
7
|
+
*/
|
|
8
|
+
export declare class FileIdempotencyStore implements IdempotencyStore {
|
|
9
|
+
private readonly dir;
|
|
10
|
+
constructor(dir: string);
|
|
11
|
+
private pathFor;
|
|
12
|
+
get(key: IdempotencyKey): Promise<IdempotencyRecord | undefined>;
|
|
13
|
+
put(record: IdempotencyRecord): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build the idempotency store the bundle's durable-execution wrapping uses,
|
|
17
|
+
* from `env.CREWHAUS_IDEMPOTENCY_STORE`. Records are namespaced by `spec` so
|
|
18
|
+
* two shapes sharing a working directory never collide. Throws on an
|
|
19
|
+
* unrecognised value so a typo fails loudly at boot rather than silently
|
|
20
|
+
* degrading exactly-once to in-memory.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createIdempotencyStore(spec: string, env?: Record<string, string | undefined>): IdempotencyStore;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop contract 0.4 (Batch F, temporal contract / G61 exactly-once half) —
|
|
3
|
+
* a DURABLE {@link IdempotencyStore} plus the env-driven factory the emitted
|
|
4
|
+
* graph / workflow bundles select their store with.
|
|
5
|
+
*
|
|
6
|
+
* `withIdempotency` (see `./index`) dedups a node/step by (runId, name,
|
|
7
|
+
* attempt), but its default {@link InMemoryIdempotencyStore} evaporates when
|
|
8
|
+
* the process exits — so it only guards against in-process double-invocation.
|
|
9
|
+
* For crash-resume exactly-once the record has to OUTLIVE the process: a
|
|
10
|
+
* restart re-executing the same run must find the prior attempt's cached
|
|
11
|
+
* result. {@link FileIdempotencyStore} persists one JSON file per key under a
|
|
12
|
+
* spec-scoped directory so a `bun agent.ts` restart (same runId) skips
|
|
13
|
+
* already-completed work instead of re-running its side effects.
|
|
14
|
+
*
|
|
15
|
+
* This is best-effort exactly-once, not transactional: a crash in the window
|
|
16
|
+
* between a node's external side effect and the store write still re-runs on
|
|
17
|
+
* resume (at-least-once). The durable record strictly TIGHTENS the guarantee
|
|
18
|
+
* over the in-memory default; it cannot make a non-transactional external
|
|
19
|
+
* effect perfectly exactly-once.
|
|
20
|
+
*
|
|
21
|
+
* Selection mirrors the channel daemon's `CREWHAUS_DEDUP_STORE` convention:
|
|
22
|
+
* - `memory` (default) — in-memory, no cross-restart resume.
|
|
23
|
+
* - `file:<dir>` / `file` — durable JSON records under
|
|
24
|
+
* `<dir>/<spec>/` (default dir `.crewhaus/idempotency`).
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { InMemoryIdempotencyStore, } from "./index";
|
|
29
|
+
function safeSegment(spec) {
|
|
30
|
+
const cleaned = spec.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
31
|
+
return cleaned.length > 0 ? cleaned : "spec";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A filesystem-backed idempotency store: one `<key>.json` per record under
|
|
35
|
+
* `dir`. `get` fails safe (a missing/corrupt file reads as "no record", so a
|
|
36
|
+
* torn write re-runs rather than throwing); `put` is a single atomic-enough
|
|
37
|
+
* `writeFileSync`.
|
|
38
|
+
*/
|
|
39
|
+
export class FileIdempotencyStore {
|
|
40
|
+
dir;
|
|
41
|
+
constructor(dir) {
|
|
42
|
+
this.dir = dir;
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
pathFor(key) {
|
|
46
|
+
return join(this.dir, `${key}.json`);
|
|
47
|
+
}
|
|
48
|
+
async get(key) {
|
|
49
|
+
const p = this.pathFor(key);
|
|
50
|
+
if (!existsSync(p))
|
|
51
|
+
return undefined;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async put(record) {
|
|
60
|
+
writeFileSync(this.pathFor(record.key), JSON.stringify(record));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const DEFAULT_IDEMPOTENCY_DIR = join(".crewhaus", "idempotency");
|
|
64
|
+
/**
|
|
65
|
+
* Build the idempotency store the bundle's durable-execution wrapping uses,
|
|
66
|
+
* from `env.CREWHAUS_IDEMPOTENCY_STORE`. Records are namespaced by `spec` so
|
|
67
|
+
* two shapes sharing a working directory never collide. Throws on an
|
|
68
|
+
* unrecognised value so a typo fails loudly at boot rather than silently
|
|
69
|
+
* degrading exactly-once to in-memory.
|
|
70
|
+
*/
|
|
71
|
+
export function createIdempotencyStore(spec, env = process.env) {
|
|
72
|
+
const raw = env["CREWHAUS_IDEMPOTENCY_STORE"] ?? "memory";
|
|
73
|
+
if (raw === "memory")
|
|
74
|
+
return new InMemoryIdempotencyStore();
|
|
75
|
+
if (raw === "file") {
|
|
76
|
+
return new FileIdempotencyStore(join(DEFAULT_IDEMPOTENCY_DIR, safeSegment(spec)));
|
|
77
|
+
}
|
|
78
|
+
if (raw.startsWith("file:")) {
|
|
79
|
+
const base = raw.slice("file:".length);
|
|
80
|
+
const dir = base.length > 0 ? base : DEFAULT_IDEMPOTENCY_DIR;
|
|
81
|
+
return new FileIdempotencyStore(join(dir, safeSegment(spec)));
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`[durable-execution] unknown CREWHAUS_IDEMPOTENCY_STORE "${raw}" — use "memory" (default) or "file:<dir>".`);
|
|
84
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -65,4 +65,21 @@ export declare function resumeFrom(store: CheckpointStore, graphRunId: GraphRunI
|
|
|
65
65
|
checkpointId: CheckpointId;
|
|
66
66
|
nextNode: string;
|
|
67
67
|
} | undefined>;
|
|
68
|
+
/**
|
|
69
|
+
* Loop contract 0.4 (Batch F, temporal contract / G61) — the linear-resume
|
|
70
|
+
* counterpart to {@link withIdempotency} for shapes that run steps
|
|
71
|
+
* sequentially in one process (the workflow target) rather than through the
|
|
72
|
+
* graph engine's `NodeContext`. `runId` is a plain string (the workflow's
|
|
73
|
+
* `CREWHAUS_RUN_ID` or a fresh id), not a branded `GraphRunId`.
|
|
74
|
+
*
|
|
75
|
+
* The first call for `(runId, name, attempt)` runs `fn` and records its
|
|
76
|
+
* result; a restart of the SAME `runId` against a durable store finds the
|
|
77
|
+
* record and skips straight to the cached value — so a crashed multi-step
|
|
78
|
+
* workflow resumes at the first not-yet-completed step instead of re-running
|
|
79
|
+
* the ones that already finished (and their side effects). With the default
|
|
80
|
+
* in-memory store it is transparent (each step runs once, keys are unique).
|
|
81
|
+
*/
|
|
82
|
+
export declare function runOnce<T>(store: IdempotencyStore, runId: string, name: string, fn: () => Promise<T>, attempt?: number): Promise<T>;
|
|
68
83
|
export { InMemoryIdempotencyStore };
|
|
84
|
+
export { type ArmedSchedule, type ArmScheduleHandlers, type WakeSchedule, armSchedule, nextCronMatch, nextWakeDelayMs, } from "./schedule";
|
|
85
|
+
export { FileIdempotencyStore, createIdempotencyStore } from "./idempotency-store";
|
package/dist/index.js
CHANGED
|
@@ -84,4 +84,40 @@ export async function resumeFrom(store, graphRunId) {
|
|
|
84
84
|
return undefined;
|
|
85
85
|
return { checkpointId: head.id, nextNode: head.nodeName };
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Loop contract 0.4 (Batch F, temporal contract / G61) — the linear-resume
|
|
89
|
+
* counterpart to {@link withIdempotency} for shapes that run steps
|
|
90
|
+
* sequentially in one process (the workflow target) rather than through the
|
|
91
|
+
* graph engine's `NodeContext`. `runId` is a plain string (the workflow's
|
|
92
|
+
* `CREWHAUS_RUN_ID` or a fresh id), not a branded `GraphRunId`.
|
|
93
|
+
*
|
|
94
|
+
* The first call for `(runId, name, attempt)` runs `fn` and records its
|
|
95
|
+
* result; a restart of the SAME `runId` against a durable store finds the
|
|
96
|
+
* record and skips straight to the cached value — so a crashed multi-step
|
|
97
|
+
* workflow resumes at the first not-yet-completed step instead of re-running
|
|
98
|
+
* the ones that already finished (and their side effects). With the default
|
|
99
|
+
* in-memory store it is transparent (each step runs once, keys are unique).
|
|
100
|
+
*/
|
|
101
|
+
export async function runOnce(store, runId, name, fn, attempt = 0) {
|
|
102
|
+
const key = idempotencyKey(runId, name, attempt);
|
|
103
|
+
const cached = await store.get(key);
|
|
104
|
+
if (cached !== undefined)
|
|
105
|
+
return cached.result;
|
|
106
|
+
const result = await fn();
|
|
107
|
+
await store.put({
|
|
108
|
+
key,
|
|
109
|
+
graphRunId: runId,
|
|
110
|
+
nodeName: name,
|
|
111
|
+
attempt,
|
|
112
|
+
result,
|
|
113
|
+
completedAt: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
87
117
|
export { InMemoryIdempotencyStore };
|
|
118
|
+
// Loop contract 0.4 (Batch F, temporal contract) — the schedule wake-loop
|
|
119
|
+
// runtime the channel/batch daemons arm their `schedule:` block with, and the
|
|
120
|
+
// durable idempotency store + env factory the graph/workflow bundles select
|
|
121
|
+
// for crash-resume exactly-once.
|
|
122
|
+
export { armSchedule, nextCronMatch, nextWakeDelayMs, } from "./schedule";
|
|
123
|
+
export { FileIdempotencyStore, createIdempotencyStore } from "./idempotency-store";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop contract 0.4 (Batch F, temporal contract / G84 schedule half) — the
|
|
3
|
+
* runtime the daemon-able bundles (channel / batch) arm their `schedule:`
|
|
4
|
+
* wake loop with. The compiler lowers `schedule:` into an `IrSchedule`
|
|
5
|
+
* (cron OR interval, durations already normalized to ms); the emitters embed
|
|
6
|
+
* the numeric-literal {@link WakeSchedule} and hand `armSchedule` the wake
|
|
7
|
+
* callback, so all of the cron arithmetic lives HERE — one tested place —
|
|
8
|
+
* instead of duplicated across two emitted daemon templates.
|
|
9
|
+
*
|
|
10
|
+
* `WakeSchedule` is the IR's `IrSchedule` MINUS `instructions` (the synthetic
|
|
11
|
+
* wake prompt is the emitter's concern, threaded into `onWake`): the schedule
|
|
12
|
+
* runtime only decides *when* to fire, never *what* to run. That keeps this
|
|
13
|
+
* package free of any `@crewhaus/ir` dependency.
|
|
14
|
+
*
|
|
15
|
+
* The cron parser is a pragmatic 5-/6-field matcher: `*`, `?`, numeric,
|
|
16
|
+
* lists (`a,b`), ranges (`a-b`), and steps (`* /n`, `a/n`, `a-b/n`), plus
|
|
17
|
+
* month/weekday names, evaluated in an IANA timezone (default UTC). The
|
|
18
|
+
* Quartz-style extensions (`L`, `W`, `#`) are not interpreted — a field
|
|
19
|
+
* containing one is treated as a wildcard so the daemon never crashes on a
|
|
20
|
+
* cron it cannot fully model; the spec's own `schedule.cron` regex is the
|
|
21
|
+
* syntactic gate.
|
|
22
|
+
*/
|
|
23
|
+
/** IR's `IrSchedule` without `instructions` — the "when", never the "what". */
|
|
24
|
+
export type WakeSchedule = {
|
|
25
|
+
readonly kind: "cron";
|
|
26
|
+
/** A 5- or 6-field cron expression (6-field is second-first, Quartz). */
|
|
27
|
+
readonly cron: string;
|
|
28
|
+
/** IANA tz the cron evaluates in; absent → UTC. */
|
|
29
|
+
readonly timezone?: string;
|
|
30
|
+
/** Random +/- delay per wake, in ms. Absent → fire exactly on time. */
|
|
31
|
+
readonly jitterMs?: number;
|
|
32
|
+
} | {
|
|
33
|
+
readonly kind: "interval";
|
|
34
|
+
readonly everyMs: number;
|
|
35
|
+
readonly jitterMs?: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The next `Date` strictly after `from` at which `cron` fires, evaluated in
|
|
39
|
+
* `timezone` (UTC when absent). Minute-granularity search; a 6-field cron's
|
|
40
|
+
* leading seconds field picks the smallest matching second inside the
|
|
41
|
+
* matched minute. Standard day-of-month / day-of-week rule: when BOTH are
|
|
42
|
+
* restricted the match is their UNION; when one is `*` the other decides.
|
|
43
|
+
*/
|
|
44
|
+
export declare function nextCronMatch(cron: string, from: Date, timezone?: string): Date;
|
|
45
|
+
/**
|
|
46
|
+
* Milliseconds to wait from `fromMs` before the next wake. Jitter (when
|
|
47
|
+
* declared) adds a uniform `+/- jitterMs` offset drawn from `rand`
|
|
48
|
+
* (`Math.random` by default); the result never goes negative.
|
|
49
|
+
*/
|
|
50
|
+
export declare function nextWakeDelayMs(schedule: WakeSchedule, fromMs: number, rand?: () => number): number;
|
|
51
|
+
export interface ArmScheduleHandlers {
|
|
52
|
+
/** Runs on every wake; awaited so overlapping ticks can't stack. */
|
|
53
|
+
readonly onWake: () => void | Promise<void>;
|
|
54
|
+
/** A thrown `onWake` lands here instead of crashing the scheduler. */
|
|
55
|
+
readonly onError?: (err: unknown) => void;
|
|
56
|
+
readonly now?: () => number;
|
|
57
|
+
readonly rand?: () => number;
|
|
58
|
+
readonly setTimer?: (fn: () => void, ms: number) => unknown;
|
|
59
|
+
readonly clearTimer?: (handle: unknown) => void;
|
|
60
|
+
}
|
|
61
|
+
export interface ArmedSchedule {
|
|
62
|
+
/** Stop the loop and clear any pending timer. Idempotent. */
|
|
63
|
+
cancel(): void;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Arm a self-rescheduling wake loop for `schedule`. Each tick computes its
|
|
67
|
+
* OWN next delay (so cron drift and per-wake jitter stay correct across a
|
|
68
|
+
* long-lived daemon) and re-arms only AFTER `onWake` resolves, so a slow tick
|
|
69
|
+
* can never overlap itself. All timing seams (`now`/`rand`/`setTimer`) are
|
|
70
|
+
* injectable for deterministic tests. Returns a cancel handle the daemon's
|
|
71
|
+
* shutdown path calls.
|
|
72
|
+
*/
|
|
73
|
+
export declare function armSchedule(schedule: WakeSchedule, handlers: ArmScheduleHandlers): ArmedSchedule;
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop contract 0.4 (Batch F, temporal contract / G84 schedule half) — the
|
|
3
|
+
* runtime the daemon-able bundles (channel / batch) arm their `schedule:`
|
|
4
|
+
* wake loop with. The compiler lowers `schedule:` into an `IrSchedule`
|
|
5
|
+
* (cron OR interval, durations already normalized to ms); the emitters embed
|
|
6
|
+
* the numeric-literal {@link WakeSchedule} and hand `armSchedule` the wake
|
|
7
|
+
* callback, so all of the cron arithmetic lives HERE — one tested place —
|
|
8
|
+
* instead of duplicated across two emitted daemon templates.
|
|
9
|
+
*
|
|
10
|
+
* `WakeSchedule` is the IR's `IrSchedule` MINUS `instructions` (the synthetic
|
|
11
|
+
* wake prompt is the emitter's concern, threaded into `onWake`): the schedule
|
|
12
|
+
* runtime only decides *when* to fire, never *what* to run. That keeps this
|
|
13
|
+
* package free of any `@crewhaus/ir` dependency.
|
|
14
|
+
*
|
|
15
|
+
* The cron parser is a pragmatic 5-/6-field matcher: `*`, `?`, numeric,
|
|
16
|
+
* lists (`a,b`), ranges (`a-b`), and steps (`* /n`, `a/n`, `a-b/n`), plus
|
|
17
|
+
* month/weekday names, evaluated in an IANA timezone (default UTC). The
|
|
18
|
+
* Quartz-style extensions (`L`, `W`, `#`) are not interpreted — a field
|
|
19
|
+
* containing one is treated as a wildcard so the daemon never crashes on a
|
|
20
|
+
* cron it cannot fully model; the spec's own `schedule.cron` regex is the
|
|
21
|
+
* syntactic gate.
|
|
22
|
+
*/
|
|
23
|
+
const MONTH_NAMES = {
|
|
24
|
+
jan: 1,
|
|
25
|
+
feb: 2,
|
|
26
|
+
mar: 3,
|
|
27
|
+
apr: 4,
|
|
28
|
+
may: 5,
|
|
29
|
+
jun: 6,
|
|
30
|
+
jul: 7,
|
|
31
|
+
aug: 8,
|
|
32
|
+
sep: 9,
|
|
33
|
+
oct: 10,
|
|
34
|
+
nov: 11,
|
|
35
|
+
dec: 12,
|
|
36
|
+
};
|
|
37
|
+
const DOW_NAMES = {
|
|
38
|
+
sun: 0,
|
|
39
|
+
mon: 1,
|
|
40
|
+
tue: 2,
|
|
41
|
+
wed: 3,
|
|
42
|
+
thu: 4,
|
|
43
|
+
fri: 5,
|
|
44
|
+
sat: 6,
|
|
45
|
+
};
|
|
46
|
+
function fieldMatches(field, n) {
|
|
47
|
+
return field === "*" || field.has(n);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse one cron field into a {@link FieldMatch}. Unrecognised tokens (the
|
|
51
|
+
* `L`/`W`/`#` Quartz extensions) collapse the whole field to `"*"` — a
|
|
52
|
+
* fail-open the daemon can survive rather than a throw that would kill the
|
|
53
|
+
* scheduler.
|
|
54
|
+
*/
|
|
55
|
+
function parseField(field, min, max, names) {
|
|
56
|
+
const trimmed = field.trim();
|
|
57
|
+
if (trimmed === "*" || trimmed === "?")
|
|
58
|
+
return "*";
|
|
59
|
+
if (/[LW#]/i.test(trimmed))
|
|
60
|
+
return "*";
|
|
61
|
+
const out = new Set();
|
|
62
|
+
const tok = (t) => {
|
|
63
|
+
const named = names?.[t.toLowerCase()];
|
|
64
|
+
return named !== undefined ? named : Number.parseInt(t, 10);
|
|
65
|
+
};
|
|
66
|
+
for (const part of trimmed.split(",")) {
|
|
67
|
+
const slash = part.indexOf("/");
|
|
68
|
+
const rangeText = slash >= 0 ? part.slice(0, slash) : part;
|
|
69
|
+
const step = slash >= 0 ? Math.max(1, Number.parseInt(part.slice(slash + 1), 10) || 1) : 1;
|
|
70
|
+
let lo;
|
|
71
|
+
let hi;
|
|
72
|
+
if (rangeText === "*" || rangeText === "?" || rangeText === "") {
|
|
73
|
+
lo = min;
|
|
74
|
+
hi = max;
|
|
75
|
+
}
|
|
76
|
+
else if (rangeText.includes("-")) {
|
|
77
|
+
const [a, b] = rangeText.split("-");
|
|
78
|
+
lo = tok(a ?? "");
|
|
79
|
+
hi = tok(b ?? "");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
lo = tok(rangeText);
|
|
83
|
+
// `a/n` (a bare start with a step) means "a, a+n, a+2n, … up to max".
|
|
84
|
+
hi = slash >= 0 ? max : lo;
|
|
85
|
+
}
|
|
86
|
+
if (Number.isNaN(lo) || Number.isNaN(hi))
|
|
87
|
+
return "*";
|
|
88
|
+
for (let v = lo; v <= hi; v += step) {
|
|
89
|
+
if (v >= min && v <= max)
|
|
90
|
+
out.add(v);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out.size === 0 ? "*" : out;
|
|
94
|
+
}
|
|
95
|
+
const WEEKDAY_INDEX = {
|
|
96
|
+
Sun: 0,
|
|
97
|
+
Mon: 1,
|
|
98
|
+
Tue: 2,
|
|
99
|
+
Wed: 3,
|
|
100
|
+
Thu: 4,
|
|
101
|
+
Fri: 5,
|
|
102
|
+
Sat: 6,
|
|
103
|
+
};
|
|
104
|
+
/** Wall-clock fields of `date` in `timezone` (UTC when absent). */
|
|
105
|
+
function wallClock(date, timezone) {
|
|
106
|
+
if (timezone === undefined) {
|
|
107
|
+
return {
|
|
108
|
+
minute: date.getUTCMinutes(),
|
|
109
|
+
hour: date.getUTCHours(),
|
|
110
|
+
dom: date.getUTCDate(),
|
|
111
|
+
month: date.getUTCMonth() + 1,
|
|
112
|
+
dow: date.getUTCDay(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
116
|
+
timeZone: timezone,
|
|
117
|
+
hour12: false,
|
|
118
|
+
month: "numeric",
|
|
119
|
+
day: "numeric",
|
|
120
|
+
hour: "numeric",
|
|
121
|
+
minute: "numeric",
|
|
122
|
+
weekday: "short",
|
|
123
|
+
}).formatToParts(date);
|
|
124
|
+
const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
|
|
125
|
+
const hour24 = Number.parseInt(get("hour"), 10);
|
|
126
|
+
return {
|
|
127
|
+
minute: Number.parseInt(get("minute"), 10),
|
|
128
|
+
// Intl with hour12:false yields "24" for midnight in some ICU builds.
|
|
129
|
+
hour: hour24 === 24 ? 0 : hour24,
|
|
130
|
+
dom: Number.parseInt(get("day"), 10),
|
|
131
|
+
month: Number.parseInt(get("month"), 10),
|
|
132
|
+
dow: WEEKDAY_INDEX[get("weekday")] ?? 0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const MINUTE_MS = 60_000;
|
|
136
|
+
const CRON_SEARCH_MINUTES = 366 * 24 * 60; // give up after a year without a match
|
|
137
|
+
/**
|
|
138
|
+
* The next `Date` strictly after `from` at which `cron` fires, evaluated in
|
|
139
|
+
* `timezone` (UTC when absent). Minute-granularity search; a 6-field cron's
|
|
140
|
+
* leading seconds field picks the smallest matching second inside the
|
|
141
|
+
* matched minute. Standard day-of-month / day-of-week rule: when BOTH are
|
|
142
|
+
* restricted the match is their UNION; when one is `*` the other decides.
|
|
143
|
+
*/
|
|
144
|
+
export function nextCronMatch(cron, from, timezone) {
|
|
145
|
+
const fields = cron.trim().split(/\s+/);
|
|
146
|
+
const [secR, minR, hourR, domR, monR, dowR] = fields.length >= 6 ? fields : ["0", ...fields];
|
|
147
|
+
const secSet = parseField(secR ?? "0", 0, 59);
|
|
148
|
+
const minSet = parseField(minR ?? "*", 0, 59);
|
|
149
|
+
const hourSet = parseField(hourR ?? "*", 0, 23);
|
|
150
|
+
const domSet = parseField(domR ?? "*", 1, 31);
|
|
151
|
+
const monSet = parseField(monR ?? "*", 1, 12, MONTH_NAMES);
|
|
152
|
+
const dowRaw = parseField(dowR ?? "*", 0, 7, DOW_NAMES);
|
|
153
|
+
// Normalize Sunday: cron accepts both 0 and 7.
|
|
154
|
+
const dowSet = dowRaw === "*" ? "*" : new Set([...dowRaw].map((d) => (d === 7 ? 0 : d)));
|
|
155
|
+
const domRestricted = domSet !== "*";
|
|
156
|
+
const dowRestricted = dowSet !== "*";
|
|
157
|
+
// Search minute-by-minute from the CURRENT minute (so a 6-field cron whose
|
|
158
|
+
// matching second is still ahead inside `from`'s own minute is found), and
|
|
159
|
+
// accept only a whole timestamp STRICTLY after `from`.
|
|
160
|
+
const fromMs = from.getTime();
|
|
161
|
+
let t = new Date(Math.floor(fromMs / MINUTE_MS) * MINUTE_MS);
|
|
162
|
+
for (let i = 0; i <= CRON_SEARCH_MINUTES; i++) {
|
|
163
|
+
const wc = wallClock(t, timezone);
|
|
164
|
+
const domOk = fieldMatches(domSet, wc.dom);
|
|
165
|
+
const dowOk = fieldMatches(dowSet, wc.dow);
|
|
166
|
+
const dayOk = domRestricted && dowRestricted ? domOk || dowOk : domRestricted ? domOk : dowOk;
|
|
167
|
+
if (dayOk &&
|
|
168
|
+
fieldMatches(minSet, wc.minute) &&
|
|
169
|
+
fieldMatches(hourSet, wc.hour) &&
|
|
170
|
+
fieldMatches(monSet, wc.month)) {
|
|
171
|
+
for (let s = 0; s <= 59; s++) {
|
|
172
|
+
if (!fieldMatches(secSet, s))
|
|
173
|
+
continue;
|
|
174
|
+
const candidate = t.getTime() + s * 1000;
|
|
175
|
+
if (candidate > fromMs)
|
|
176
|
+
return new Date(candidate);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
t = new Date(t.getTime() + MINUTE_MS);
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`cron "${cron}" produced no wake within a year`);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Milliseconds to wait from `fromMs` before the next wake. Jitter (when
|
|
185
|
+
* declared) adds a uniform `+/- jitterMs` offset drawn from `rand`
|
|
186
|
+
* (`Math.random` by default); the result never goes negative.
|
|
187
|
+
*/
|
|
188
|
+
export function nextWakeDelayMs(schedule, fromMs, rand = Math.random) {
|
|
189
|
+
const base = schedule.kind === "interval"
|
|
190
|
+
? schedule.everyMs
|
|
191
|
+
: nextCronMatch(schedule.cron, new Date(fromMs), schedule.timezone).getTime() - fromMs;
|
|
192
|
+
const jitter = schedule.jitterMs !== undefined && schedule.jitterMs > 0
|
|
193
|
+
? (rand() * 2 - 1) * schedule.jitterMs
|
|
194
|
+
: 0;
|
|
195
|
+
return Math.max(0, Math.round(base + jitter));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Arm a self-rescheduling wake loop for `schedule`. Each tick computes its
|
|
199
|
+
* OWN next delay (so cron drift and per-wake jitter stay correct across a
|
|
200
|
+
* long-lived daemon) and re-arms only AFTER `onWake` resolves, so a slow tick
|
|
201
|
+
* can never overlap itself. All timing seams (`now`/`rand`/`setTimer`) are
|
|
202
|
+
* injectable for deterministic tests. Returns a cancel handle the daemon's
|
|
203
|
+
* shutdown path calls.
|
|
204
|
+
*/
|
|
205
|
+
export function armSchedule(schedule, handlers) {
|
|
206
|
+
const now = handlers.now ?? Date.now;
|
|
207
|
+
const rand = handlers.rand ?? Math.random;
|
|
208
|
+
const setTimer = handlers.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
209
|
+
const clearTimer = handlers.clearTimer ?? ((h) => clearTimeout(h));
|
|
210
|
+
let cancelled = false;
|
|
211
|
+
let handle;
|
|
212
|
+
const scheduleNext = () => {
|
|
213
|
+
if (cancelled)
|
|
214
|
+
return;
|
|
215
|
+
const delay = nextWakeDelayMs(schedule, now(), rand);
|
|
216
|
+
handle = setTimer(() => {
|
|
217
|
+
if (cancelled)
|
|
218
|
+
return;
|
|
219
|
+
void (async () => {
|
|
220
|
+
try {
|
|
221
|
+
await handlers.onWake();
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
handlers.onError?.(err);
|
|
225
|
+
}
|
|
226
|
+
scheduleNext();
|
|
227
|
+
})();
|
|
228
|
+
}, delay);
|
|
229
|
+
};
|
|
230
|
+
scheduleNext();
|
|
231
|
+
return {
|
|
232
|
+
cancel() {
|
|
233
|
+
cancelled = true;
|
|
234
|
+
if (handle !== undefined)
|
|
235
|
+
clearTimer(handle);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/durable-execution",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Exactly-once node execution wrapper over graph-engine — idempotency keys + crash-replay",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"test": "bun test src"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@crewhaus/checkpoint-store": "0.
|
|
19
|
-
"@crewhaus/errors": "0.
|
|
18
|
+
"@crewhaus/checkpoint-store": "0.4.0",
|
|
19
|
+
"@crewhaus/errors": "0.4.0"
|
|
20
20
|
},
|
|
21
21
|
"license": "Apache-2.0",
|
|
22
22
|
"author": {
|