@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 +42 -0
- package/src/index.test.ts +88 -0
- package/src/index.ts +114 -0
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 };
|