@crewhaus/durable-execution 0.1.0 → 0.1.2
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 +7 -12
- package/src/index.test.ts +162 -1
- package/src/index.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/durable-execution",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Exactly-once node execution wrapper over graph-engine — idempotency keys + crash-replay",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/checkpoint-store": "0.
|
|
16
|
-
"@crewhaus/errors": "0.
|
|
15
|
+
"@crewhaus/checkpoint-store": "0.1.2",
|
|
16
|
+
"@crewhaus/errors": "0.1.2"
|
|
17
17
|
},
|
|
18
18
|
"license": "Apache-2.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Max Meier",
|
|
21
|
-
"email": "max@
|
|
22
|
-
"url": "https://
|
|
21
|
+
"email": "max@crewhaus.ai",
|
|
22
|
+
"url": "https://crewhaus.ai"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
@@ -31,12 +31,7 @@
|
|
|
31
31
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
|
-
"access": "
|
|
34
|
+
"access": "public"
|
|
35
35
|
},
|
|
36
|
-
"files": [
|
|
37
|
-
"src",
|
|
38
|
-
"README.md",
|
|
39
|
-
"LICENSE",
|
|
40
|
-
"NOTICE"
|
|
41
|
-
]
|
|
36
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
42
37
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -7,7 +7,14 @@ import {
|
|
|
7
7
|
createCheckpointStore,
|
|
8
8
|
newGraphRunId,
|
|
9
9
|
} from "@crewhaus/checkpoint-store";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type IdempotencyRecord,
|
|
12
|
+
type IdempotencyStore,
|
|
13
|
+
InMemoryIdempotencyStore,
|
|
14
|
+
idempotencyKey,
|
|
15
|
+
resumeFrom,
|
|
16
|
+
withIdempotency,
|
|
17
|
+
} from "./index";
|
|
11
18
|
|
|
12
19
|
let tmp: string;
|
|
13
20
|
let store: CheckpointStore;
|
|
@@ -69,6 +76,120 @@ describe("withIdempotency", () => {
|
|
|
69
76
|
await w1(grun, "n", { x: 0 });
|
|
70
77
|
expect(invocations).toBe(2);
|
|
71
78
|
});
|
|
79
|
+
|
|
80
|
+
test("honours an injected store and persists a well-formed record", async () => {
|
|
81
|
+
const grun = newGraphRunId();
|
|
82
|
+
const store = new InMemoryIdempotencyStore();
|
|
83
|
+
const wrapped = withIdempotency<{ v: number }>(async () => ({ v: 7 }), { store, attempt: 3 });
|
|
84
|
+
const out = await wrapped(grun, "node-z", { v: 0 });
|
|
85
|
+
expect(out).toEqual({ v: 7 });
|
|
86
|
+
// The record must be findable in the *shared* store under the derived key.
|
|
87
|
+
const rec = await store.get(idempotencyKey(grun, "node-z", 3));
|
|
88
|
+
expect(rec).toBeDefined();
|
|
89
|
+
expect(rec?.graphRunId).toBe(grun);
|
|
90
|
+
expect(rec?.nodeName).toBe("node-z");
|
|
91
|
+
expect(rec?.attempt).toBe(3);
|
|
92
|
+
expect(rec?.result).toEqual({ v: 7 });
|
|
93
|
+
// completedAt is a valid ISO-8601 timestamp.
|
|
94
|
+
expect(Number.isNaN(Date.parse(rec?.completedAt ?? ""))).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a second wrapper sharing the store reads the cached result", async () => {
|
|
98
|
+
const grun = newGraphRunId();
|
|
99
|
+
const store = new InMemoryIdempotencyStore();
|
|
100
|
+
let firstCalls = 0;
|
|
101
|
+
let secondCalls = 0;
|
|
102
|
+
const first = withIdempotency<{ v: number }>(
|
|
103
|
+
async () => {
|
|
104
|
+
firstCalls += 1;
|
|
105
|
+
return { v: 1 };
|
|
106
|
+
},
|
|
107
|
+
{ store },
|
|
108
|
+
);
|
|
109
|
+
const second = withIdempotency<{ v: number }>(
|
|
110
|
+
async () => {
|
|
111
|
+
secondCalls += 1;
|
|
112
|
+
return { v: 2 };
|
|
113
|
+
},
|
|
114
|
+
{ store },
|
|
115
|
+
);
|
|
116
|
+
const a = await first(grun, "shared", { v: 0 });
|
|
117
|
+
const b = await second(grun, "shared", { v: 0 });
|
|
118
|
+
expect(a).toEqual({ v: 1 });
|
|
119
|
+
// Cache hit: the second wrapper never runs its inner fn.
|
|
120
|
+
expect(b).toEqual({ v: 1 });
|
|
121
|
+
expect(firstCalls).toBe(1);
|
|
122
|
+
expect(secondCalls).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("works with a custom IdempotencyStore implementation", async () => {
|
|
126
|
+
const grun = newGraphRunId();
|
|
127
|
+
const backing = new Map<string, IdempotencyRecord>();
|
|
128
|
+
const calls = { get: 0, put: 0 };
|
|
129
|
+
const custom: IdempotencyStore = {
|
|
130
|
+
async get(key) {
|
|
131
|
+
calls.get += 1;
|
|
132
|
+
return backing.get(key);
|
|
133
|
+
},
|
|
134
|
+
async put(record) {
|
|
135
|
+
calls.put += 1;
|
|
136
|
+
backing.set(record.key, record);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
let invocations = 0;
|
|
140
|
+
const wrapped = withIdempotency<{ n: number }>(
|
|
141
|
+
async () => {
|
|
142
|
+
invocations += 1;
|
|
143
|
+
return { n: invocations };
|
|
144
|
+
},
|
|
145
|
+
{ store: custom },
|
|
146
|
+
);
|
|
147
|
+
const a = await wrapped(grun, "node-c", { n: 0 });
|
|
148
|
+
const b = await wrapped(grun, "node-c", { n: 0 });
|
|
149
|
+
expect(a).toEqual({ n: 1 });
|
|
150
|
+
expect(b).toEqual({ n: 1 });
|
|
151
|
+
expect(invocations).toBe(1);
|
|
152
|
+
expect(calls.put).toBe(1);
|
|
153
|
+
expect(calls.get).toBe(2);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("InMemoryIdempotencyStore", () => {
|
|
158
|
+
test("get returns undefined for an absent key", async () => {
|
|
159
|
+
const store = new InMemoryIdempotencyStore();
|
|
160
|
+
expect(await store.get("does-not-exist")).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("put then get round-trips the record", async () => {
|
|
164
|
+
const store = new InMemoryIdempotencyStore();
|
|
165
|
+
const grun = newGraphRunId();
|
|
166
|
+
const record: IdempotencyRecord = {
|
|
167
|
+
key: idempotencyKey(grun, "n", 0),
|
|
168
|
+
graphRunId: grun,
|
|
169
|
+
nodeName: "n",
|
|
170
|
+
attempt: 0,
|
|
171
|
+
result: { ok: true },
|
|
172
|
+
completedAt: new Date(0).toISOString(),
|
|
173
|
+
};
|
|
174
|
+
await store.put(record);
|
|
175
|
+
expect(await store.get(record.key)).toEqual(record);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("put overwrites an existing record for the same key", async () => {
|
|
179
|
+
const store = new InMemoryIdempotencyStore();
|
|
180
|
+
const grun = newGraphRunId();
|
|
181
|
+
const key = idempotencyKey(grun, "n", 0);
|
|
182
|
+
const base = {
|
|
183
|
+
key,
|
|
184
|
+
graphRunId: grun,
|
|
185
|
+
nodeName: "n",
|
|
186
|
+
attempt: 0,
|
|
187
|
+
completedAt: new Date(0).toISOString(),
|
|
188
|
+
};
|
|
189
|
+
await store.put({ ...base, result: { v: 1 } });
|
|
190
|
+
await store.put({ ...base, result: { v: 2 } });
|
|
191
|
+
expect((await store.get(key))?.result).toEqual({ v: 2 });
|
|
192
|
+
});
|
|
72
193
|
});
|
|
73
194
|
|
|
74
195
|
describe("resumeFrom", () => {
|
|
@@ -85,4 +206,44 @@ describe("resumeFrom", () => {
|
|
|
85
206
|
const grun = newGraphRunId();
|
|
86
207
|
expect(await resumeFrom(store, grun)).toBeUndefined();
|
|
87
208
|
});
|
|
209
|
+
|
|
210
|
+
test("returns undefined when meta exists but has no head", async () => {
|
|
211
|
+
const grun = newGraphRunId();
|
|
212
|
+
// A freshly-branched run can have meta with `head` absent (see
|
|
213
|
+
// checkpoint-store GraphRunMeta docs). Model it directly via a stub so
|
|
214
|
+
// the test is deterministic and does not depend on branch internals.
|
|
215
|
+
const stub = {
|
|
216
|
+
async meta() {
|
|
217
|
+
return { version: 1 as const, graphRunId: grun, createdAt: new Date(0).toISOString() };
|
|
218
|
+
},
|
|
219
|
+
async load() {
|
|
220
|
+
throw new Error("load must not be called when head is undefined");
|
|
221
|
+
},
|
|
222
|
+
} as unknown as CheckpointStore;
|
|
223
|
+
expect(await resumeFrom(stub, grun)).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("returns undefined when head checkpoint is missing", async () => {
|
|
227
|
+
const grun = newGraphRunId();
|
|
228
|
+
// meta names a head, but the underlying checkpoint cannot be loaded
|
|
229
|
+
// (e.g. a corrupted/partially-deleted store). resumeFrom must fail
|
|
230
|
+
// safe rather than dereference an undefined checkpoint.
|
|
231
|
+
let loadCalls = 0;
|
|
232
|
+
const stub = {
|
|
233
|
+
async meta() {
|
|
234
|
+
return {
|
|
235
|
+
version: 1 as const,
|
|
236
|
+
graphRunId: grun,
|
|
237
|
+
head: "ckpt_00000000000000aa",
|
|
238
|
+
createdAt: new Date(0).toISOString(),
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
async load() {
|
|
242
|
+
loadCalls += 1;
|
|
243
|
+
return undefined;
|
|
244
|
+
},
|
|
245
|
+
} as unknown as CheckpointStore;
|
|
246
|
+
expect(await resumeFrom(stub, grun)).toBeUndefined();
|
|
247
|
+
expect(loadCalls).toBe(1);
|
|
248
|
+
});
|
|
88
249
|
});
|
package/src/index.ts
CHANGED
|
@@ -54,7 +54,10 @@ export interface IdempotencyStore {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
class InMemoryIdempotencyStore implements IdempotencyStore {
|
|
57
|
-
private readonly entries
|
|
57
|
+
private readonly entries: Map<IdempotencyKey, IdempotencyRecord>;
|
|
58
|
+
constructor() {
|
|
59
|
+
this.entries = new Map<IdempotencyKey, IdempotencyRecord>();
|
|
60
|
+
}
|
|
58
61
|
async get(key: IdempotencyKey): Promise<IdempotencyRecord | undefined> {
|
|
59
62
|
return this.entries.get(key);
|
|
60
63
|
}
|