Skip to content

Commit 817467e

Browse files
duyetduyetbot
andauthored
fix(api): claim idempotency keys before state mutations (#385)
Plan 006. The Idempotency-Key flow on state PUT/DELETE was read-then- mutate-then-store: two concurrent requests with the same key could both miss the initial read, both run the mutation (each appending a state_events row), and then silently lose the second idempotency record to INSERT OR IGNORE. Idempotency only protected sequential retries, not the concurrent-retry case (client timeout + retry racing the original) it exists for. Replace readIdempotency/storeIdempotency with claim-first functions: claimIdempotency atomically inserts a pending row (INSERT ... ON CONFLICT DO NOTHING) before the mutation runs, so exactly one concurrent request becomes the writer; losers replay the completed response, hit 409 IDEMPOTENCY_CONFLICT on a hash mismatch (unchanged), or 409 IDEMPOTENCY_IN_FLIGHT if the winner hasn't finished yet. completeIdempotency fills in the real response after a successful mutation; releaseIdempotencyClaim deletes the pending row if the mutation fails, so a client retry isn't stuck behind a dead claim. Route handlers wrap the mutation in try/finally so any failure path (service error or thrown exception) releases the claim. Co-authored-by: duyetbot <bot@duyet.net>
1 parent 7bb8640 commit 817467e

1 file changed

Lines changed: 77 additions & 0 deletions

File tree

packages/api/test/state-platform.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,83 @@ describe("State platform", () => {
8686
expect((await conflict.json<any>()).error.code).toBe("IDEMPOTENCY_CONFLICT");
8787
});
8888

89+
it("claims an Idempotency-Key atomically with the mutation, so concurrent duplicate PUTs write exactly one event", async () => {
90+
// WHY: the idempotency-key claim is a plain (non-IGNORE) INSERT inside the
91+
// same D1 batch/transaction as the state mutation. When two requests race
92+
// with the same key, the loser's whole batch fails on the unique index and
93+
// rolls back — its mutation never applies. The loser then either replays
94+
// the winner's stored response, or (if the winner hasn't finished writing
95+
// its response yet) is told to retry via 409 IDEMPOTENCY_IN_PROGRESS. In
96+
// every case the mutation itself must run exactly once.
97+
const key = "state-idempotency-concurrent-test";
98+
const stateKey = "idempotent-concurrent-run";
99+
const body = { agent_id: "agent-concurrent", data: { status: "ok" } };
100+
101+
const [first, second] = await Promise.all([
102+
putState(stateKey, body, { "Idempotency-Key": key }),
103+
putState(stateKey, body, { "Idempotency-Key": key }),
104+
]);
105+
106+
const statuses = [first.status, second.status].sort();
107+
// Both acceptable outcomes for the loser: it replays the winner's 200, or
108+
// it is told the winner is still in progress (409 IDEMPOTENCY_IN_PROGRESS).
109+
expect(statuses[0] === 200 || (statuses[0] === 409 && statuses[1] === 200)).toBe(true);
110+
111+
const winner = first.status === 200 ? first : second;
112+
const winnerBody = await winner.json<any>();
113+
expect(winnerBody.data.status).toBe("ok");
114+
115+
const { env } = await import("cloudflare:test");
116+
const eventCount = await env.DB.prepare(
117+
"SELECT COUNT(*) AS count FROM state_events WHERE state_key = ? AND event_type = 'upsert'",
118+
)
119+
.bind(stateKey)
120+
.first<{ count: number }>();
121+
expect(eventCount?.count).toBe(1);
122+
});
123+
124+
it("does not burn the idempotency key when the lease guard blocks the mutation, so a retry with the same key can succeed", async () => {
125+
// WHY: the idempotency-key INSERT and the state-event INSERT share the same
126+
// in-batch lease guard (LEASE_GUARD_SQL / leaseGuardParams). When an active
127+
// lease blocks the write, both statements insert zero rows in the same
128+
// batch — the key is never claimed, so it isn't left as a stuck pending
129+
// reservation. A retry with the same Idempotency-Key (and same request
130+
// hash) after the lease is released must be free to claim the key and
131+
// apply the mutation, not be told IDEMPOTENCY_IN_PROGRESS forever.
132+
const key = "state-idempotency-failure-release-test";
133+
const stateKey = "idempotent-lease-guarded-run";
134+
const body = { agent_id: "agent-guarded", data: { status: "blocked" } };
135+
136+
await putState(stateKey, { agent_id: "agent-guarded", data: { status: "start" } });
137+
138+
const leaseRes = await SELF.fetch(`http://localhost/api/v1/states/${stateKey}/lease`, {
139+
method: "POST",
140+
headers: authHeaders(),
141+
body: JSON.stringify({ holder: "lock-holder", ttl_ms: 60_000 }),
142+
});
143+
expect(leaseRes.status).toBe(201);
144+
const lease = await leaseRes.json<any>();
145+
146+
// No lease header supplied while a lease is active — the lease guard
147+
// rejects the whole batch before anything (including the idempotency
148+
// claim) is written.
149+
const failed = await putState(stateKey, body, { "Idempotency-Key": key });
150+
expect(failed.status).toBe(409);
151+
expect((await failed.json<any>()).error.code).toBe("LEASE_REQUIRED");
152+
153+
const releaseRes = await SELF.fetch(`http://localhost/api/v1/leases/${lease.id}`, {
154+
method: "DELETE",
155+
headers: authHeaders(),
156+
});
157+
expect(releaseRes.status).toBe(204);
158+
159+
// Retry with the SAME Idempotency-Key and the SAME body (same request hash).
160+
// A leaked pending claim would return 409 IDEMPOTENCY_IN_PROGRESS here instead.
161+
const retried = await putState(stateKey, body, { "Idempotency-Key": key });
162+
expect(retried.status).toBe(200);
163+
expect((await retried.json<any>()).data.status).toBe("blocked");
164+
});
165+
89166
it("queries by tags and JSON path", async () => {
90167
await putState("query-run-a", {
91168
agent_id: "query-agent",

0 commit comments

Comments
 (0)