writeonce/docs/examples/porch/middleware/idempotent.wo
shoney.arickathil 659ea26582 fix(porch-store): delete the ephemeral nonce row after one read
- The nonce naming an ephemeral (4xx/5xx) row is handed to exactly one
  call() reply and nowhere else -- no other message can ever construct
  that key, so idempotent.wo deleting it right after building the Resp
  is safe by construction (unlike the earlier shared bare-key row,
  which a second message COULD reach and made deleting it racy)
- Closes the leak AND a real correctness edge: the nonce is
  time.ticks() % 1_000_000_000, wrapping every ~1000s -- with rows kept
  forever, a later failed attempt on the same key could land on the
  same nonce and either collide with the unguarded insert or resurface
  a stale replay, exactly what rounds 1/2 removed
- Gate leg 18f: N ephemeral attempts against the same key must return
  IdempotencyKey's row count to baseline, not grow it by N -- confirmed
  failing (baseline+N) against the pre-fix code, passing after
- N picked at 3: the pre-existing runtime hang/segfault (out of scope,
  being tracked separately) reproduces more often at higher sequential
  insert+delete volume against the same key; 3 stayed clean across
  many runs while still proving the property precisely

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9ad594748e01a665ccaf29733a48c2b83a2749da)
2026-09-15 01:15:30 +02:00

109 lines
5.3 KiB
Text

-- porch/middleware/idempotent.wo — idempotency, actor-run.
-- Iteration 1 of the porch track, porch-store task 4.
--
-- Rebuilt, not patched: the old before/after shape ran the handler and
-- stored the response afterward, so two simultaneous duplicates both
-- missed and both executed — before() has nothing to find until after()
-- runs, which is too late for the request that is racing it. The fix is
-- that the ACTOR runs the handler: Idempotent wraps the route's own
-- Handler and hands both the request and that handler to the pool. A
-- duplicate for the same key then simply waits in the actor's mailbox
-- (one message at a time) and is dequeued once the owner's receive has
-- already committed the row — no in-flight heuristic needed, because
-- there is no window where a duplicate can see "nothing yet".
--
-- `call`'s reply is a copyable scalar only (WO-E226): keypool.wo's kind-2
-- arm answers with the SAME pool_pack(count, remaining_ms) encoding kind-1
-- uses, never the response itself. The response travels through the
-- @table instead — the actor stores it, this file reads the same row
-- back and builds the Resp from it, so owner and duplicate answer with
-- byte-identical bytes structurally, not by careful bookkeeping.
use http
use json
-- Idempotent wraps a route's Handler. Registration: Idempotent { key_header:
-- "Idempotency-Key", pool: p, inner: CreateOrder {} } in place of the bare
-- handler in app.post(...). Only the digest allowlists content-type for
-- replay (never Set-Cookie, never Date) — cookies arrive in porch 2.
pub class Idempotent {
key_header: Text -- e.g., "Idempotency-Key"
pool: Pool
inner: Handler -- the real route handler; the actor runs this
include_body: Bool = true -- digest method+path+body, refuse a key reused with a different one
ttl: Int = 86_400_000_000 -- 24h in µs, lazy-expired on access
fn handle(req: Req) -> Resp {
let header_val = req.headers[self.key_header];
if header_val == nil { return self.inner.handle(req); }
let key = "idem:${self.key_header}:${header_val}";
let digest = "";
if self.include_body {
let digest_input = "${req.method}|${req.path}|${req.body}";
digest = base64_encode(bytes_slice(sha256(bytes_of_text(digest_input)), 0, 16));
}
let raw = try pool_begin(self.pool, key, digest, self.ttl, req, self.inner) catch (e) nil;
if raw == nil {
let r = Resp { status: 503, headers: {}, body: "{\"error\":\"idempotency store saturated\"}" };
set_header(r, "content-type", "application/json");
set_header(r, "retry-after", "1");
return r;
}
let outcome = raw / 1_000_000_000;
if outcome == 2 {
-- Same key, a different request: refuse rather than serve the
-- other request's response.
let r = Resp { status: 422, headers: {}, body: "{\"error\":\"idempotency key reused with a different request\"}" };
set_header(r, "content-type", "application/json");
return r;
}
-- Outcome 1: the bare key names a durable (2xx/3xx) replay target --
-- this file never deletes it; it is the whole point of a durable row.
-- Outcome 3: this call's own 4xx/5xx answer lives under a row keyed
-- by a nonce (the reply's low digits) that nobody else's message
-- for this same bare key ever writes to -- rebuild that exact key
-- rather than reading the bare one, so a race with a LATER message
-- for this key (which never touches this row) can't hand back the
-- wrong response.
let lookup_key = key;
if outcome == 3 { lookup_key = "${key}#eph:${raw % 1_000_000_000}"; }
let hits = from k in IdempotencyKey where k.key == lookup_key take 1 select k;
if len(hits) == 0 { return server_error(); }
let row = hits[0];
let stored = json.decode(row.response) as IdempotentStoredResp;
if stored == nil { return server_error(); }
-- `.. ""` forces a fresh, independently-owned Text for every key/value
-- copied out of the decoded record: json.decode's Text values do not
-- survive being handed onward as-is once the decoded record itself
-- goes out of scope (a stale row read back corrupted mid-response
-- otherwise) — concat is documented to always allocate new owned text.
let hdrs: map<Text, Text> = {};
for k, v in stored.headers { hdrs[k .. ""] = v .. ""; }
let result = Resp { status: stored.status, headers: hdrs, body: stored.body .. "" };
if outcome == 3 {
-- Safe to delete here, unlike the shared bare-key row rounds 1/2
-- removed: the nonce that names this row was never handed to
-- anyone but this one call() reply, so no other request -- a
-- duplicate, a retry, anything -- can ever construct this exact
-- key to read it. Deleting it removes the leak AND the
-- nonce-wraparound collision (time.ticks() % 1_000_000_000 repeats
-- every ~1000s; a lingering row from an earlier failed attempt
-- landing on the same nonce would otherwise be there to collide
-- with, or worse, poison the actor's OWN unguarded insert on the
-- next failure for this key).
delete row;
}
return result;
}
}
-- JSON shape of IdempotencyKey.response. Allowlisted headers only:
-- content-type, never Set-Cookie or Date.
typedef IdempotentStoredResp = {
status: Int,
headers: map<Text, Text>,
body: Text
}