@crewhaus/idempotency-keys 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 +41 -0
- package/src/index.test.ts +102 -0
- package/src/index.ts +98 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/idempotency-keys",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Idempotency-key dedup store for the BATCH consumer (Section 23 BATCH)",
|
|
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/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/idempotency-keys"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/idempotency-keys#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createInMemoryIdempotencyStore, idempotencyKey, withIdempotency } from "./index.js";
|
|
3
|
+
|
|
4
|
+
describe("idempotencyKey", () => {
|
|
5
|
+
test("same (jobId, attempt) → same key (T9 invariant)", () => {
|
|
6
|
+
const a = idempotencyKey("job_001", 1);
|
|
7
|
+
const b = idempotencyKey("job_001", 1);
|
|
8
|
+
expect(a).toBe(b);
|
|
9
|
+
expect(a).toMatch(/^[0-9a-f]{24}$/);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("different attempts produce different keys", () => {
|
|
13
|
+
expect(idempotencyKey("job_001", 1)).not.toBe(idempotencyKey("job_001", 2));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("different jobIds produce different keys", () => {
|
|
17
|
+
expect(idempotencyKey("a", 1)).not.toBe(idempotencyKey("b", 1));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("createInMemoryIdempotencyStore", () => {
|
|
22
|
+
test("get returns undefined for unknown keys", async () => {
|
|
23
|
+
const store = createInMemoryIdempotencyStore();
|
|
24
|
+
expect(await store.get("nope")).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("set then get within TTL returns the value", async () => {
|
|
28
|
+
const store = createInMemoryIdempotencyStore<string>();
|
|
29
|
+
await store.set("k", "v", 1000);
|
|
30
|
+
expect(await store.get("k")).toBe("v");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("entries past TTL are evicted on next access", async () => {
|
|
34
|
+
let t = 1_000;
|
|
35
|
+
const store = createInMemoryIdempotencyStore<string>({ now: () => t });
|
|
36
|
+
await store.set("k", "v", 100);
|
|
37
|
+
expect(await store.get("k")).toBe("v");
|
|
38
|
+
t = 1_200;
|
|
39
|
+
expect(await store.get("k")).toBeUndefined();
|
|
40
|
+
expect(store.size()).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("withIdempotency", () => {
|
|
45
|
+
test("first call invokes the handler; second call with same key hits cache", async () => {
|
|
46
|
+
const store = createInMemoryIdempotencyStore<string>();
|
|
47
|
+
let invocations = 0;
|
|
48
|
+
const wrapped = withIdempotency<{ x: number }, string>(
|
|
49
|
+
async (input) => {
|
|
50
|
+
invocations += 1;
|
|
51
|
+
return `result-${input.x}-${invocations}`;
|
|
52
|
+
},
|
|
53
|
+
{ store, ttlMs: 60_000 },
|
|
54
|
+
);
|
|
55
|
+
const k = idempotencyKey("job_a", 1);
|
|
56
|
+
const first = await wrapped({ x: 7 }, k);
|
|
57
|
+
const second = await wrapped({ x: 7 }, k);
|
|
58
|
+
expect(invocations).toBe(1);
|
|
59
|
+
expect(first.fromCache).toBe(false);
|
|
60
|
+
expect(second.fromCache).toBe(true);
|
|
61
|
+
expect(second.value).toBe(first.value);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("handler errors do NOT poison the cache (next attempt re-runs)", async () => {
|
|
65
|
+
const store = createInMemoryIdempotencyStore<string>();
|
|
66
|
+
let invocations = 0;
|
|
67
|
+
const wrapped = withIdempotency<unknown, string>(
|
|
68
|
+
async () => {
|
|
69
|
+
invocations += 1;
|
|
70
|
+
if (invocations === 1) throw new Error("transient");
|
|
71
|
+
return "ok";
|
|
72
|
+
},
|
|
73
|
+
{ store, ttlMs: 60_000 },
|
|
74
|
+
);
|
|
75
|
+
const k = idempotencyKey("job_x", 1);
|
|
76
|
+
await expect(wrapped(null, k)).rejects.toThrow("transient");
|
|
77
|
+
const retry = await wrapped(null, k);
|
|
78
|
+
expect(retry.value).toBe("ok");
|
|
79
|
+
expect(retry.fromCache).toBe(false);
|
|
80
|
+
expect(invocations).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("T9 property: 5 retries with the same key call the handler once and return identical results", async () => {
|
|
84
|
+
const store = createInMemoryIdempotencyStore<string>();
|
|
85
|
+
let invocations = 0;
|
|
86
|
+
const wrapped = withIdempotency<unknown, string>(
|
|
87
|
+
async () => {
|
|
88
|
+
invocations += 1;
|
|
89
|
+
return `unique-${Math.random()}`; // would differ on each call
|
|
90
|
+
},
|
|
91
|
+
{ store, ttlMs: 60_000 },
|
|
92
|
+
);
|
|
93
|
+
const key = idempotencyKey("job_p", 1);
|
|
94
|
+
const results: string[] = [];
|
|
95
|
+
for (let i = 0; i < 5; i++) {
|
|
96
|
+
const r = await wrapped(null, key);
|
|
97
|
+
results.push(r.value);
|
|
98
|
+
}
|
|
99
|
+
expect(invocations).toBe(1);
|
|
100
|
+
expect(new Set(results).size).toBe(1); // all identical
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R7 `idempotency-keys` — Section 23 BATCH.
|
|
3
|
+
*
|
|
4
|
+
* Per-key cached-result store the BATCH consumer wraps the user's
|
|
5
|
+
* handler with so a job that's already been processed (e.g. after a
|
|
6
|
+
* crash → retry, or after a transient nack that produced a partial
|
|
7
|
+
* result) returns the cached value without re-invoking the model.
|
|
8
|
+
*
|
|
9
|
+
* Key shape: `idempotencyKey(jobId, attempt)` — the consumer derives
|
|
10
|
+
* the key from the job's id + attempt counter on the queue. Same job
|
|
11
|
+
* + same attempt → same key, so two consumers grabbing the same job
|
|
12
|
+
* (the "double-pull" race when visibility leases collide) get the
|
|
13
|
+
* same cached result.
|
|
14
|
+
*
|
|
15
|
+
* The default `createInMemoryIdempotencyStore` is a Map with a per-key
|
|
16
|
+
* TTL. Eviction happens lazily on next get/set; tests can override the
|
|
17
|
+
* clock via the `now` injection seam.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
export type IdempotencyKey = string;
|
|
22
|
+
|
|
23
|
+
export interface IdempotencyStore<TValue = unknown> {
|
|
24
|
+
get(key: IdempotencyKey): Promise<TValue | undefined>;
|
|
25
|
+
/** TTL in ms; entries past TTL are evicted on next access. */
|
|
26
|
+
set(key: IdempotencyKey, value: TValue, ttlMs: number): Promise<void>;
|
|
27
|
+
/** Test seam — current entry count after lazy eviction. */
|
|
28
|
+
size(): number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compose a stable key from a job id + attempt number. We hash so the
|
|
33
|
+
* key fits the typical kv-store key-length limits regardless of job-id
|
|
34
|
+
* shape.
|
|
35
|
+
*/
|
|
36
|
+
export function idempotencyKey(jobId: string, attempt: number): IdempotencyKey {
|
|
37
|
+
return createHash("sha256").update(`${jobId}:${attempt}`).digest("hex").slice(0, 24);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type InMemoryStoreOptions = {
|
|
41
|
+
readonly now?: () => number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function createInMemoryIdempotencyStore<TValue = unknown>(
|
|
45
|
+
opts: InMemoryStoreOptions = {},
|
|
46
|
+
): IdempotencyStore<TValue> {
|
|
47
|
+
const now = opts.now ?? Date.now;
|
|
48
|
+
type Entry = { value: TValue; expiresAt: number };
|
|
49
|
+
const map = new Map<IdempotencyKey, Entry>();
|
|
50
|
+
|
|
51
|
+
function evictExpired(): void {
|
|
52
|
+
const t = now();
|
|
53
|
+
for (const [k, v] of map.entries()) {
|
|
54
|
+
if (v.expiresAt <= t) map.delete(k);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async get(key) {
|
|
60
|
+
evictExpired();
|
|
61
|
+
const entry = map.get(key);
|
|
62
|
+
if (entry === undefined) return undefined;
|
|
63
|
+
return entry.value;
|
|
64
|
+
},
|
|
65
|
+
async set(key, value, ttlMs) {
|
|
66
|
+
evictExpired();
|
|
67
|
+
map.set(key, { value, expiresAt: now() + ttlMs });
|
|
68
|
+
},
|
|
69
|
+
size() {
|
|
70
|
+
evictExpired();
|
|
71
|
+
return map.size;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `withIdempotency` wraps a handler so a re-invocation with the same
|
|
78
|
+
* (jobId, attempt) tuple is served from cache. The first call's
|
|
79
|
+
* SUCCESSFUL result is cached; failures (the handler throws) bypass the
|
|
80
|
+
* cache so the next attempt re-runs the handler. This matches the
|
|
81
|
+
* SQS-style "ack on success, nack on failure" contract: the cache is
|
|
82
|
+
* for ack'd work, not for failed attempts.
|
|
83
|
+
*/
|
|
84
|
+
export function withIdempotency<TInput, TResult>(
|
|
85
|
+
handler: (input: TInput, key: IdempotencyKey) => Promise<TResult>,
|
|
86
|
+
opts: {
|
|
87
|
+
readonly store: IdempotencyStore<TResult>;
|
|
88
|
+
readonly ttlMs: number;
|
|
89
|
+
},
|
|
90
|
+
): (input: TInput, key: IdempotencyKey) => Promise<{ value: TResult; fromCache: boolean }> {
|
|
91
|
+
return async (input, key) => {
|
|
92
|
+
const cached = await opts.store.get(key);
|
|
93
|
+
if (cached !== undefined) return { value: cached, fromCache: true };
|
|
94
|
+
const value = await handler(input, key);
|
|
95
|
+
await opts.store.set(key, value, opts.ttlMs);
|
|
96
|
+
return { value, fromCache: false };
|
|
97
|
+
};
|
|
98
|
+
}
|