@crewhaus/durable-execution 0.1.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crewhaus/durable-execution",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Exactly-once node execution wrapper over graph-engine — idempotency keys + crash-replay",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/checkpoint-store": "0.0.0",
16
+ "@crewhaus/errors": "0.0.0"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "Max Meier",
21
+ "email": "max@studiomax.io",
22
+ "url": "https://studiomax.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/durable-execution"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/durable-execution#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/crewhaus/factory/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ]
42
+ }
@@ -0,0 +1,88 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ type CheckpointStore,
7
+ createCheckpointStore,
8
+ newGraphRunId,
9
+ } from "@crewhaus/checkpoint-store";
10
+ import { idempotencyKey, resumeFrom, withIdempotency } from "./index";
11
+
12
+ let tmp: string;
13
+ let store: CheckpointStore;
14
+
15
+ beforeEach(() => {
16
+ tmp = mkdtempSync(join(tmpdir(), "durable-exec-"));
17
+ store = createCheckpointStore({ rootDir: tmp });
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tmp, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("idempotencyKey", () => {
25
+ test("deterministic for the same inputs", () => {
26
+ const grun = newGraphRunId();
27
+ expect(idempotencyKey(grun, "a", 0)).toBe(idempotencyKey(grun, "a", 0));
28
+ });
29
+ test("different attempts produce different keys", () => {
30
+ const grun = newGraphRunId();
31
+ expect(idempotencyKey(grun, "a", 0)).not.toBe(idempotencyKey(grun, "a", 1));
32
+ });
33
+ test("different nodes produce different keys", () => {
34
+ const grun = newGraphRunId();
35
+ expect(idempotencyKey(grun, "a", 0)).not.toBe(idempotencyKey(grun, "b", 0));
36
+ });
37
+ });
38
+
39
+ describe("withIdempotency", () => {
40
+ test("first call invokes inner; second call returns cached value", async () => {
41
+ let invocations = 0;
42
+ const wrapped = withIdempotency(async (_g, _n, _p: { v: number }) => {
43
+ invocations += 1;
44
+ return { v: 42 };
45
+ });
46
+ const grun = newGraphRunId();
47
+ const a = await wrapped(grun, "node-a", { v: 0 });
48
+ const b = await wrapped(grun, "node-a", { v: 999 });
49
+ expect(invocations).toBe(1);
50
+ expect(a).toEqual({ v: 42 });
51
+ expect(b).toEqual({ v: 42 });
52
+ });
53
+
54
+ test("different attempt indexes re-invoke inner", async () => {
55
+ let invocations = 0;
56
+ const grun = newGraphRunId();
57
+ const w0 = withIdempotency<{ x: number }>(async () => {
58
+ invocations += 1;
59
+ return { x: invocations };
60
+ });
61
+ const w1 = withIdempotency<{ x: number }>(
62
+ async () => {
63
+ invocations += 1;
64
+ return { x: invocations };
65
+ },
66
+ { attempt: 1 },
67
+ );
68
+ await w0(grun, "n", { x: 0 });
69
+ await w1(grun, "n", { x: 0 });
70
+ expect(invocations).toBe(2);
71
+ });
72
+ });
73
+
74
+ describe("resumeFrom", () => {
75
+ test("returns the head's checkpointId + nodeName", async () => {
76
+ const grun = newGraphRunId();
77
+ await store.save({ graphRunId: grun, nodeName: "plan", state: 1 });
78
+ const cp = await store.save({ graphRunId: grun, nodeName: "execute", state: 2 });
79
+ const r = await resumeFrom(store, grun);
80
+ expect(r?.checkpointId).toBe(cp.id);
81
+ expect(r?.nextNode).toBe("execute");
82
+ });
83
+
84
+ test("returns undefined for an unknown run", async () => {
85
+ const grun = newGraphRunId();
86
+ expect(await resumeFrom(store, grun)).toBeUndefined();
87
+ });
88
+ });
package/src/index.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Catalog R11 `durable-execution` — exactly-once node-execution wrapper.
3
+ *
4
+ * The graph-engine writes a checkpoint after every successful node, so
5
+ * a crashed run can resume by loading the last checkpoint and replaying
6
+ * the next edge. This package adds two further guarantees:
7
+ *
8
+ * 1. Idempotency keys. Each node-execution attempt computes
9
+ * `idempotencyKey(graphRunId, nodeName, attemptIndex)` and a
10
+ * backing store keeps a record of completed attempts. If the same
11
+ * key is seen twice, the second invocation returns the cached
12
+ * result instead of re-executing — preventing double-spend in
13
+ * side-effectful tools.
14
+ *
15
+ * 2. Resume helpers. `resumeFrom(store, graphRunId)` returns the
16
+ * next-node hint a caller can pass to `graph.run({ resumeFrom })`,
17
+ * computed by walking the checkpoint chain backward to the latest
18
+ * successfully-completed node. The default policy ("re-run the
19
+ * next edge") composes with graph-engine; alternative policies
20
+ * (e.g. retry the failed node) can be plugged in via the optional
21
+ * `policy` callback.
22
+ *
23
+ * Layer R11. Pairs with `checkpoint-store` (R7) and `graph-engine` (R11).
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+ import type { CheckpointId, CheckpointStore, GraphRunId } from "@crewhaus/checkpoint-store";
28
+
29
+ export type IdempotencyKey = string;
30
+
31
+ export function idempotencyKey(
32
+ graphRunId: GraphRunId,
33
+ nodeName: string,
34
+ attemptIndex = 0,
35
+ ): IdempotencyKey {
36
+ return createHash("sha256")
37
+ .update(`${graphRunId}|${nodeName}|${attemptIndex}`)
38
+ .digest("hex")
39
+ .slice(0, 24);
40
+ }
41
+
42
+ export type IdempotencyRecord = {
43
+ readonly key: IdempotencyKey;
44
+ readonly graphRunId: GraphRunId;
45
+ readonly nodeName: string;
46
+ readonly attempt: number;
47
+ readonly result: unknown;
48
+ readonly completedAt: string;
49
+ };
50
+
51
+ export interface IdempotencyStore {
52
+ get(key: IdempotencyKey): Promise<IdempotencyRecord | undefined>;
53
+ put(record: IdempotencyRecord): Promise<void>;
54
+ }
55
+
56
+ class InMemoryIdempotencyStore implements IdempotencyStore {
57
+ private readonly entries = new Map<IdempotencyKey, IdempotencyRecord>();
58
+ async get(key: IdempotencyKey): Promise<IdempotencyRecord | undefined> {
59
+ return this.entries.get(key);
60
+ }
61
+ async put(record: IdempotencyRecord): Promise<void> {
62
+ this.entries.set(record.key, record);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Wrap a node fn with idempotency. The first call executes normally and
68
+ * caches the result against `idempotencyKey(graphRunId, nodeName,
69
+ * attempt)`. Subsequent calls with the same key return the cached
70
+ * result without invoking the inner fn — which is exactly what crash
71
+ * recovery needs: if the engine restarts mid-node, the same idempotency
72
+ * key resolves to the prior side-effect's result.
73
+ */
74
+ export function withIdempotency<S>(
75
+ inner: (graphRunId: GraphRunId, nodeName: string, prev: S) => Promise<S>,
76
+ opts: { readonly store?: IdempotencyStore; readonly attempt?: number } = {},
77
+ ): (graphRunId: GraphRunId, nodeName: string, prev: S) => Promise<S> {
78
+ const store = opts.store ?? new InMemoryIdempotencyStore();
79
+ const attempt = opts.attempt ?? 0;
80
+ return async (graphRunId, nodeName, prev) => {
81
+ const key = idempotencyKey(graphRunId, nodeName, attempt);
82
+ const cached = await store.get(key);
83
+ if (cached !== undefined) return cached.result as S;
84
+ const result = await inner(graphRunId, nodeName, prev);
85
+ await store.put({
86
+ key,
87
+ graphRunId,
88
+ nodeName,
89
+ attempt,
90
+ result,
91
+ completedAt: new Date().toISOString(),
92
+ });
93
+ return result;
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Read the latest checkpoint of `graphRunId` and return the
99
+ * `{ checkpointId, nextNode }` hint for `graph.run({ resumeFrom })`.
100
+ * `nextNode` is the LAST committed node; the engine will re-evaluate
101
+ * its outgoing edge to figure out where to go next.
102
+ */
103
+ export async function resumeFrom(
104
+ store: CheckpointStore,
105
+ graphRunId: GraphRunId,
106
+ ): Promise<{ checkpointId: CheckpointId; nextNode: string } | undefined> {
107
+ const meta = await store.meta(graphRunId);
108
+ if (meta?.head === undefined) return undefined;
109
+ const head = await store.load(graphRunId, meta.head);
110
+ if (head === undefined) return undefined;
111
+ return { checkpointId: head.id, nextNode: head.nodeName };
112
+ }
113
+
114
+ export { InMemoryIdempotencyStore };