- idempotency built, reviewed, then reverted WHOLE to the tag archive/porch-idempotency. Not a design failure: it passed its gates. It provokes a C-runtime SIGSEGV in wo_arena_alloc/wo_str_new under concurrent call()-parked callers - the evidence for that attribution: over ten gate runs every failure was an idempotency leg and none was the limiter's, which drives the same pool through the same call/park machinery. The begin arm has 5x the allocation sites inside receive and moves a whole Req plus a Handler through the mailbox - before the split the suite reported 0 to 6 failures run to run; after it, five consecutive runs at 56 checks, 0 failures - PoolMsg loses digest/req/handler, and NullHandler/dummy_req/fresh_req go with them — every rate-limit count used to allocate a throwaway Req it never read - IdempotencyKey is KEPT and commented: the schema is settled and the digest-as-column decision cost a review round to get right - the limiter's saturation 503 has no leg of its own now (§19 drove Idempotent). Stated in the README rather than papered over — a deterministic leg needs a slow actor, and only the reverted arm was - new: porch 9 (idempotency, on hold) and language 41 (the arena crash, with the reproduction harness and the evidence that localises it) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 79e6da4465133dc555e913c960d544ef1c7bedd8)
207 lines
9.2 KiB
Text
207 lines
9.2 KiB
Text
-- porch/middleware/keypool.wo — the key pool: an actor per shard, picked by
|
|
-- hash of the key, that serializes rate-limit counting (this file, kind 1)
|
|
-- and idempotency begin (Task 4, kind 2) against the @table rows in
|
|
-- store.wo. This is the only file that knows a pool exists — the
|
|
-- middlewares call through it and never touch RateLimitCounter
|
|
-- themselves. IdempotencyKey is the one exception: the response has to
|
|
-- travel through that table (a Resp cannot ride the mailbox — see
|
|
-- below), so idempotent.wo reads the row a begin call already committed.
|
|
--
|
|
-- `call`'s reply crosses the actor boundary as a single copyable scalar
|
|
-- (WO-E226 — no class, no Text can ride it). The exact count is decided
|
|
-- atomically inside `receive`; `pool_count` packs it with the window's
|
|
-- remaining time into one Int and unpacks that into the `Verdict` callers
|
|
-- actually read, so the packing never leaks outside this file. kind 2
|
|
-- (Task 4) reuses the exact same pool_pack scheme for its outcome code —
|
|
-- WO-E226 forces every `receive` in the program to agree on one return
|
|
-- type, so a second encoding is not an option.
|
|
|
|
use time
|
|
|
|
-- To a pool actor. kind 1 = count (this file); kind 2 = begin (Task 4
|
|
-- fills in the arm — the fields below are already shaped for it: the
|
|
-- bare idempotency key travels in `key`, the body digest in `digest`,
|
|
-- and the actor runs `handler` against `req` itself so a duplicate waits
|
|
-- in the mailbox rather than needing a held reply).
|
|
-- To a pool actor. `kind` is kept even though only one kind exists today:
|
|
-- idempotency's `kind: 2` arm was built, reviewed and then REVERTED (see
|
|
-- archive/porch-idempotency), and it will come back. Adding a second kind is
|
|
-- a field and an `if`, not a redesign.
|
|
class PoolMsg {
|
|
kind: Int
|
|
key: Text
|
|
limit: Int -- count: max requests per window
|
|
window: Int -- count: window size, µs
|
|
}
|
|
|
|
-- What the limiter reads back from a count. `allowed` and `limit` are
|
|
-- filled in by `pool_count` — the caller already knows `limit`, it is the
|
|
-- one it sent. `count` and `reset_at` come from the actor.
|
|
class Verdict {
|
|
allowed: Bool
|
|
count: Int
|
|
limit: Int
|
|
reset_at: Int -- wall-clock ms (time.now()) when this key's window resets
|
|
}
|
|
|
|
-- PoolMsg requires `req`/`handler` on every construction (an actor
|
|
-- NOTE: this file used to carry NullHandler, dummy_req() and fresh_req().
|
|
-- They existed ONLY because PoolMsg had to carry a Req and a Handler for
|
|
-- idempotency's kind-2 arm, which meant every rate-limit count allocated a
|
|
-- throwaway Req (four maps) it never read. With that arm reverted the
|
|
-- placeholders go too, and counting stops paying for a feature it never
|
|
-- used. They are preserved with the arm in archive/porch-idempotency.
|
|
|
|
-- Packs (count, remaining-ms-in-window) into one Int: count * 1e9 +
|
|
-- remaining_ms, remaining_ms clamped to stay under 1e9 (~11.5 days —
|
|
-- far past any realistic rate-limit window). That clamp only blurs the
|
|
-- advisory reset header on an absurdly long window; it never touches the
|
|
-- count, which is the correctness-critical half.
|
|
fn pool_pack(count: Int, remaining_ms: Int) -> Int {
|
|
let r = remaining_ms;
|
|
if r < 0 { r = 0; }
|
|
if r >= 1_000_000_000 { r = 999_999_999; }
|
|
return count * 1_000_000_000 + r;
|
|
}
|
|
|
|
-- One actor per shard. Reads the row for the key, decides, and writes the
|
|
-- new count by assigning to the row's field — that writes through and
|
|
-- maintains indexes; never delete-then-insert as an update of the SAME
|
|
-- row (the window prune below IS a delete-then-insert, but of a fresh
|
|
-- row for the new window — the stale row is retired, not mutated).
|
|
class KeyActor {
|
|
fn receive(msg: PoolMsg) -> Int {
|
|
|
|
-- Only kind 1 exists today. The `kind: 2` arm — idempotency, where the
|
|
-- actor ran the route handler inside this receive so a duplicate waited
|
|
-- in the mailbox — was built, reviewed and then REVERTED. It is whole in
|
|
-- the tag archive/porch-idempotency, which doubles as the reproduction
|
|
-- harness for the C-runtime crash that caused the revert: a SIGSEGV in
|
|
-- wo_arena_alloc / wo_str_new under concurrent call()-parked callers.
|
|
-- That arm allocated 5x what this one does inside receive and moved a
|
|
-- whole Req plus a Handler through the mailbox; over ten gate runs every
|
|
-- failure was one of its legs, and none were this one's.
|
|
-- kind 1: count.
|
|
let now = time.ticks();
|
|
let hits = from c in RateLimitCounter where c.key == msg.key take 1 select c;
|
|
|
|
if len(hits) == 0 {
|
|
insert RateLimitCounter { key: msg.key, count: 1, window: now };
|
|
return pool_pack(1, msg.window / 1000);
|
|
}
|
|
|
|
let row = hits[0];
|
|
if now - row.window > msg.window {
|
|
-- the window fully elapsed: prune the stale row rather than reset it
|
|
-- in place — resetting keeps one row forever for every key ever
|
|
-- seen, an unbounded leak for IP-keyed limiting. There is no
|
|
-- sweeper; this lazy expiry on access is it.
|
|
delete row;
|
|
insert RateLimitCounter { key: msg.key, count: 1, window: now };
|
|
return pool_pack(1, msg.window / 1000);
|
|
}
|
|
|
|
row.count = row.count + 1;
|
|
let remaining_us = row.window + msg.window - now;
|
|
if remaining_us < 0 { remaining_us = 0; }
|
|
return pool_pack(row.count, remaining_us / 1000);
|
|
}
|
|
}
|
|
|
|
class PoolSlot {
|
|
a: actor PoolMsg
|
|
}
|
|
|
|
class Pool {
|
|
actors: multi PoolSlot
|
|
}
|
|
|
|
-- Spawns n identical actors and returns the pool. n is a capacity knob:
|
|
-- too small and a hot key's mailbox saturates under load (a `call` trap,
|
|
-- answered 503 by the middleware — never a silent bypass). n < 1 is a
|
|
-- caller misconfiguration, not a capacity choice, and guarding it HERE
|
|
-- (not in pool_select's division) is what matters: every pool_select call
|
|
-- runs inside the middleware's own `try ... catch (e) { print_err(...);
|
|
-- nil }`, so a mod-by-zero trap there would be misreported as ordinary
|
|
-- 503 saturation forever (though now at least logged, not silently
|
|
-- swallowed), never surfacing the real bug on its own.
|
|
pub fn make_pool(n: Int) -> Pool {
|
|
let count = n;
|
|
if count < 1 { count = 1; }
|
|
let actors: multi PoolSlot = [];
|
|
let i = 0;
|
|
while i < count {
|
|
push(actors, PoolSlot { a: spawn KeyActor {} });
|
|
i = i + 1;
|
|
}
|
|
return Pool { actors: actors };
|
|
}
|
|
|
|
-- Pool itself is demand-promoted to traced (WO-E222) the moment an app
|
|
-- aliases it — e.g. Limiter/Idempotent's own `pool: Pool` field, read on
|
|
-- every request without being consumed — so it can never live in an
|
|
-- actor's state or a message. PoolSlot is not: WO-E222's contains_traced
|
|
-- check only recurses into a field typed as a class name (or a `multi`/
|
|
-- `map` of one); `a: actor PoolMsg` is an actor handle, a different case
|
|
-- entirely, so it never pulls PoolSlot (or `multi PoolSlot`) into the
|
|
-- traced set the way wrapping it in Pool does. An actor CAN hold `multi
|
|
-- PoolSlot` directly in its own state — the exact shape chat/main.wo's
|
|
-- `Room { members: multi Mem }` already uses for a multi of actor
|
|
-- handles — which is what makes real per-connection sharding possible:
|
|
-- call make_pool(n) ONCE at process start, hand pool_slots(pool) to every
|
|
-- connection actor's spawn, and each one rebuilds a transient Pool via
|
|
-- pool_of(self.slots) wherever Limiter/Idempotent needs one. Calling
|
|
-- make_pool per connection instead (the natural misreading of this pair
|
|
-- sitting right after a capacity-sizing knob) gives every connection its
|
|
-- own actors and silently restores the lost-increment race this whole
|
|
-- design exists to prevent.
|
|
--
|
|
-- Both functions copy field-by-field, the same trick fresh_req uses above:
|
|
-- an actor handle is a plain, freely-copyable scalar (not traced), so
|
|
-- rebuilding each PoolSlot by value produces a list with no lingering
|
|
-- alias into the traced Pool (pool_slots) or the caller's own copy
|
|
-- (pool_of) — never a value some other reader could still be holding.
|
|
pub fn pool_slots(p: Pool) -> multi PoolSlot {
|
|
let out: multi PoolSlot = [];
|
|
for s in p.actors { push(out, PoolSlot { a: s.a }); }
|
|
return out;
|
|
}
|
|
|
|
pub fn pool_of(s: multi PoolSlot) -> Pool {
|
|
let out: multi PoolSlot = [];
|
|
for x in s { push(out, PoolSlot { a: x.a }); }
|
|
return Pool { actors: out };
|
|
}
|
|
|
|
-- Hashes a key to one of the pool's actors — sum of bytes modulo n, a
|
|
-- shard selector, not a security hash. The same key always selects the
|
|
-- same actor, which is the entire per-key serialization mechanism.
|
|
pub fn pool_select(pool: Pool, key: Text) -> actor PoolMsg {
|
|
let sum = 0;
|
|
let i = 0;
|
|
while i < len(key) {
|
|
sum = sum + byte_at(key, i);
|
|
i = i + 1;
|
|
}
|
|
let idx = sum % len(pool.actors);
|
|
return pool.actors[idx].a;
|
|
}
|
|
|
|
-- The count accessor every later task's limiter calls. Unpacks the
|
|
-- actor's scalar reply into the Verdict the limiter reads.
|
|
pub fn pool_count(pool: Pool, key: Text, limit: Int, window: Int) -> Verdict {
|
|
let a = pool_select(pool, key);
|
|
let raw = call(a, PoolMsg { kind: 1, key: key, limit: limit, window: window });
|
|
let count = raw / 1_000_000_000;
|
|
let remaining_ms = raw % 1_000_000_000;
|
|
return Verdict {
|
|
allowed: count <= limit,
|
|
count: count,
|
|
limit: limit,
|
|
reset_at: time.now() + remaining_ms
|
|
};
|
|
}
|
|
|
|
-- NOTE: pool_begin() lived here — the accessor idempotent.wo called to run a
|
|
-- request through the actor. Reverted with the kind-2 arm; whole in the tag
|
|
-- archive/porch-idempotency.
|