writeonce/docs/stories/porch/01-store-backed-middleware.md
shoney.arickathil 84cfbb1885 docs(porch-store): saturation gate leg closes out porch 1
- Add saturation leg (scripts/web-app-accept.sh): one-actor pool,
  WO_MAILBOX=2, 15 concurrent requests, exactly 3 served + 12 answer
  503; execution count matches the 200 count, retry-after + real
  cause verified on the 503s
- Guard make_pool(n<1) by clamping in make_pool itself, not
  pool_select's division -- that trap runs inside the middleware's
  own try/catch and would be swallowed as ordinary saturation forever
- README: rate limiting + idempotency ledger rows moved to done,
  scoped to what the gate proves; documented Handler-decorator
  shape, Pool aliasing (WO-E222), call's scalar-only reply (WO-E226),
  pool size as a capacity decision
- Story: Progress table filled with real hashes, 7/9 acceptance
  criteria marked verified with citations, 2 marked verified by
  construction (never gated even in the original plan), status: done
- Status board: standup entry, porch 1 pending row updated
- Recorded a pre-existing runtime hang (main() returns cleanly, OS
  process sometimes hangs under concurrent call()-parked callers)
  that also reaches the new leg's teardown; contained with kill -9
  rather than asserted, so it can't flake the leg's actual subject

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

317 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
track: porch
iteration: "1"
status: done
readiness: ready
---
# porch 1 — store-backed middleware: rate limiting and idempotency
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §2.
> Spec: [`2026-08-29-porch-store-backed-middleware-design.md`](../../superpowers/specs/2026-08-29-porch-store-backed-middleware-design.md).
>
> **Rewritten 2026-08-29** after the brainstorm settled every fork. The
> iteration's premise changed: it was scoped as the cheapest slice because it
> needed "only a `@table` and `time.ticks`", and it now serializes through an
> actor pool. That is not new runtime surface — `spawn`, `send`, `call`,
> `monitor` and `time.after` all landed with the actor-lifecycle work — but it
> is more than the original framing, and the reason is in History below.
>
> **First deliberately because it is the cheapest.** Both features need only a
> `@table` and `time.ticks`, both of which already exist — no new builtin, no
> cookie, no change to `Resp`. It exists to prove the store pattern and the gate
> shape on low-risk work before iterations 2–4 touch the runtime and the public
> response type.
## Goals
- **A rate limiter that survives a restart.** Fixed-window counting keyed by
client, answering 429 with the conventional headers when the window is spent.
Fiber's `limiter` keeps counters in memory by default and expects Redis for
anything real; porch's live in a `@table`, so they are WAL-durable and
crash-recoverable for free. That is the difference worth demonstrating, and it
is why the acceptance criteria include a restart.
- **Idempotent replay of unsafe requests.** A client resending a POST with the
same idempotency key gets the stored response and the handler does not run
twice. This is the correctness feature the storefront sample has silently
needed since it grew a checkout.
- **Establish the store convention for iterations 2–4.** Sessions and CSRF will
want the same shape. Decide it once, here, on the cheap slice.
## The design, as settled
One rule, inherited by porch 2 (sessions) and 3 (CSRF):
> **Serialize through an actor. Persist in a `@table`. Never read-modify-write
> from a handler fiber.**
| Concern | Owner |
| --- | --- |
| per-key ordering, in-flight ownership, waiter lists | a sharded pool of actors, selected by hash of the key |
| counters, stored responses, request digests | `@table` rows — WAL-durable, replayed at boot |
| deciding whether a request passes | the actor, never the handler fiber |
The read-modify-write is the defect both features shared: a handler reads a
count, adds one and writes it back, so two interleaved fibers lose an
increment. An actor processes one message at a time, so routing both features
through the pool buys per-key serialization with no locks and no polling. A
pool rather than one actor because ordering is needed *per key*, never
globally.
## Progress
| Phase | State |
| --- | --- |
| A — store convention | ✅ `519d411` two purpose-shaped tables; `3a9bddc` added the digest column the refusal criterion needs |
| B — rate limiter | ✅ `676e651` the shared key-pool actor (also C's foundation), `153fd29` its `reset_at` unit fix; `a653dd0` the limiter delegates all counting to the pool, `831e9d8` `trust_proxy`'s absent-XFF fallback fix |
| C — idempotency | ✅ `eae1b06` rebuilt on the same pool actor (the actor runs the route's `Handler` itself); three fix rounds: `e61015f` never replay a transient 5xx, `464147a` close the ephemeral-row race, `9ad5947` delete the ephemeral row after one read |
| D — gate and ledger | ✅ + this commit — the pool-saturation gate leg (§19, `scripts/web-app-accept.sh`), README ledger rows to ✅, pool-size capacity docs, `make_pool`'s mod-by-zero guard |
Superseded pre-rewrite commits (`5b1e82a`, `aee7926`, `5c3544d`) are kept in
History below rather than deleted — the reasoning for the rebuild survives
there.
## Phases
### Phase A — the store convention
- Decide the store shape (see Info fork 1) and write it down before any
middleware exists, because three later iterations inherit it.
- Add the `@table` classes for a counter row and a stored-response row, with
the secondary indexes their lookups need — the read path is an equality
probe, which is the only index shape the engine has.
- Decide and document the expiry discipline: rows are pruned lazily on access,
not by a background sweeper, because porch has no timer and iteration 30 owns
scheduled work.
- Verify: `woc docs/examples/porch/` typechecks entry-less as a library; the
new tables appear in the WAL and replay across a restart.
### Phase B — the rate limiter
- A `Limiter` middleware class with a `before` that counts and either passes or
short-circuits with 429 — the `?Resp` short-circuit the chain already has.
- Key selection: reuse `client_ip(req)` and the existing trusted-proxy
judgement rather than inventing a second one. A keyed-by-principal variant
falls out for free once `req.principal` is set by an auth middleware.
- The response headers on both paths, and a `Retry-After` on the refusal.
- Window arithmetic on `time.ticks` (µs monotonic), not `time.now` — a
wall-clock jump must not hand out a free window.
- Verify: a burst crosses the threshold at exactly N, the window rolls, the
counters survive `SIGTERM` + restart.
### Phase C — idempotency
- An `Idempotent` middleware pair: `before` looks the key up and replays a hit;
`after` stores the response for a miss. This is the first real user of the
`after` chain for something other than headers, which is worth noting.
- Decide what is part of the identity: the key header alone, or key plus a
digest of method+path+body (`sha256` exists). Replaying a stored response for
a *different* body under a reused key is the failure mode that matters.
- In-flight collision handling: a second request arriving while the first is
still running. Fiber takes a lock; porch's shard model means the honest
answer is probably to refuse with 409 rather than to block.
- Verify: replay returns the stored response, the handler's side effect happens
exactly once, a reused key with a different body is refused, concurrent
duplicates do not both execute.
### Phase D — the gate and the ledger
- Extend `scripts/web-app-accept.sh` with the checks above, including the
restart leg — a durability claim that no gate exercises is not a claim.
- Update porch's README ledger rows for both features, and record in the
status board what landed versus what was planned.
- Verify: `just web-app` and `just site` both green; `just linkcheck` clean.
## Acceptance Criteria
- **Given** a limiter of N requests per window, **when** a client sends N+1,
**then** the first N succeed and the last is 429 with `Retry-After` set.
**Met** — `scripts/web-app-accept.sh` §17a: 5 requests pass, the 6th is 429,
carrying `Retry-After` and `X-RateLimit-Remaining: 0`.
- **Given** counters at their limit, **when** the process is SIGTERMed and
restarted, **then** the client is still limited — the counters replayed from
the WAL rather than resetting to zero. **Met** — §17b: same client, same
key, still 429 after a restart against the same `WO_DATA`.
- **Given** a window that has fully elapsed, **when** the same client returns,
**then** it is served, and the expired row is pruned on that access. **Met
by construction, not gate-exercised** — `keypool.wo`'s kind-1 arm deletes the
stale row and inserts a fresh count-1 row once `now - row.window >
msg.window`; no gate leg waits out a full window (the keypool leg's own
window is 60s) to observe it end to end.
- **Given** the system clock jumping backwards, **when** the window is
evaluated, **then** no extra allowance is granted (`time.ticks` is monotonic).
**Met by construction, not gate-exercised** — the counting path reads only
`time.ticks()`, never `time.now()`; `time.now()` feeds only the advisory
`reset_at`/`X-RateLimit-Reset` value, never the count itself. No gate leg
fakes a backward clock jump.
- **Given** a POST with an idempotency key that has been seen, **when** it is
replayed, **then** the stored response is returned byte-identically and the
handler's side effect count is unchanged — proven by a row count, not by a
log line. **Met** — §18a: same key + same body both 200, byte-identical
bodies, `ExecMark` row count stays 1.
- **Given** a reused idempotency key with a different request body, **when** it
arrives, **then** it is refused rather than answered with the other request's
response. **Met** — §18b: 200 then 422, the 422 body is the refusal (never
the first response), the refused request never ran the handler.
- **Given** two identical keyed requests in flight at once, **when** both are
dispatched, **then** exactly one executes and the other receives that one's
stored response — never a refusal, never a partial write. *(Restated
2026-08-29: this used to permit 409. Blocking supersedes it — the duplicate
parks on `call` until the owner reports.)* **Met** — §18c: two genuinely
concurrent duplicates both answer 200 with identical bytes, overlap timing
proves genuine concurrency, and `ExecMark` shows exactly one execution.
- **Given** N concurrent requests for one limiter key, **when** they are
counted, **then** the total is exactly N and no increment is lost. *(Added:
unreachable before the pool, and the defect that most undermines a limiter.)*
**Met** — §17c: 30 genuinely parallel requests, the count afterward is exact.
- **Given** a saturated actor pool, **when** a request arrives, **then** it is
refused with 503 rather than served uncounted. *(Added: fail-closed, because
saturating the pool must not become the limiter's bypass.)* **Met** — §19: a
one-actor pool with `WO_MAILBOX` shrunk to 2, 15 concurrent requests, exactly
3 served (1 running + 2 queued) and 12 answer 503, each 503 carrying
`Retry-After` and naming the real cause; the `SatMark` execution count
matches the 200 count exactly — no overflow request ran uncounted.
Seven of nine criteria are gate-proven end to end
(§17a/§17b/§17c/§18a/§18b/§18c/§19). The remaining two — window-elapse pruning
and clock-monotonicity — are implemented and hold by construction and code
inspection; neither was gated even in the original phase plan below, and
gating them (a real wait-out-a-window run, a faked backward clock) is future
work, not this task's. The concurrency legs needed genuine parallelism: a test
that cannot fail before the fix is not a test, and `scripts/web-app-accept.sh`
§17c/§18c/§19 all use backgrounded, concurrently-launched clients rather than
a sequential loop.
## Out Of Scope
- **A pluggable `Storage` interface.** Fiber abstracts it so one middleware runs
on memory or Redis. porch has one store, and interfaces here are structural —
an abstraction with exactly one implementor is decoration, which iteration 37
learned the hard way about `Component`. Concrete `@table` until a second
backend actually exists.
- **Sliding-window or token-bucket algorithms.** Fixed window is what the
sample needs; a better algorithm is a later slice with a measurement behind
it.
- **Distributed limiting across processes.** One program owns its database;
cross-program state is language
[databasev2 9](../databasev2/09-cross-program-tables.md).
- **A background expiry sweeper.** Lazy pruning on access, deliberately —
porch has no scheduler. **Corrected 2026-08-29**: this used to say "no timer
exists (`time.after` is still a reserved builtin id)". That is false —
`time.after` is builtin 90 and implemented (`runtime/src/builtin.c`, via
`wo_vm_timer_after`), along with `spawn` (68), `send` (69), `call` (88) and
`monitor` (89). The exclusion stands on its own merits: `time.after` is a
one-shot timer aimed at an actor, not a recurring sweep. But the *reason*
given was wrong, and it is the claim that made this iteration look cheaper
than it is.
- **The TTL cache middleware** — language
[iteration 18](../language-runtime-database/18-memory-db-features.md) owns it,
spec already approved. Do not build a second cache here.
## Info
**All three settled 2026-08-29** — kept with their outcomes rather than
deleted, so the reasoning survives:
1. **One store or two?** A single generic key/value/expiry table serving both
features, or a purpose-shaped table each. Leaning two: the columns genuinely
differ (a counter is an Int, a stored response is status + headers + body),
and a generic table would force everything through `Text`, which is how the
framework's cache ended up storing JSON strings.
2. **What is the limiter's key when there is no auth?** `client_ip(req)` reads
`X-Forwarded-For`, which a direct client can forge. Behind the mandated TLS
proxy that is fine; on an open port it is not. `net.peer(fd)` gives the real
peer — decide which is authoritative and reuse whatever the trusted-proxy
slice concludes rather than deciding twice.
3. **Does idempotency store headers?** Fiber has `KeepResponseHeaders` because
replaying `Set-Cookie` or a fresh `Date` is usually wrong. porch has no
cookies yet (iteration 2), so this is cheap to decide now and expensive to
retrofit later.
Nothing here needs a new runtime primitive — still true, and now verified
rather than assumed: `call` is "a send that WAITS", whose park/reply protocol
lives in the VM, and that is exactly the blocking primitive the design needs.
**Outcomes:**
1. **Two stores**, as leaned. Confirmed by construction in `519d411`; a
generic table would have forced a counter and a response body through the
same `Text` column.
2. **The peer address, unless the app declares otherwise.** A `trust_proxy`
flag defaulting to off: off keys on `net.peer(req.conn)`, which cannot be
forged; on keys on the left-most `X-Forwarded-For` entry. Only the deployer
knows the topology, so the declaration belongs in their code. The built
version branched on `req.ctx["verified_proxy"]`, which nothing anywhere
sets — a dead branch. Verifying the proxy is genuinely story 35's, and
`client_ip` says so in its own comment.
3. **`content-type` only.** Settled as built, and settled correctly: replaying
a stored `Set-Cookie` or a stale `Date` is wrong, and cookies arrive in
iteration 2, so this is cheap now and expensive later.
## History
**2026-08-30 — Task 5: the saturation leg, and closing out.** The gate now
proves fail-closed saturation (§19 of `scripts/web-app-accept.sh`): a one-actor
pool, `WO_MAILBOX` shrunk to 2, 15 genuinely concurrent requests — exactly 3
served (1 running + 2 queued) and 12 answer 503, retry-after set, the real
cause named, and the execution count matches the 200 count exactly. Also
fixed while wiring pool size: `make_pool(n)` with `n < 1` was a mod-by-zero in
`pool_select`; guarding it in `pool_select` alone would not have helped —
every `pool_select` call runs inside the middleware's own `try ... catch (e)
nil`, so the trap would have been swallowed and misreported as ordinary 503
saturation forever. `make_pool` now clamps `n < 1` to 1.
Four things discovered building Tasks 2–4, not in the original design, now
recorded in `docs/examples/porch/README.md` (not only here, since anyone
wiring this into a real app needs them): `Idempotent` is a `Handler`
decorator, not a `Middleware`; `Pool` cannot live in actor state or a message
(WO-E222) and must be re-wrapped from a bare `actor PoolMsg` handle per use;
a `call` reply must be a copyable scalar (WO-E226), which is why the response
travels through the `@table`; and pool size is a capacity decision — a
saturated pool fails closed with 503, never a silent bypass.
**Runtime defects found during this work — C runtime, not porch bugs:**
- `try EXPR catch (e) nil` cannot distinguish a literal `Int 0` reply from a
trap. Worked around by never packing a zero outcome code (`pool_pack` in
`middleware/keypool.wo`).
- A `Text`/map value read off `json.decode(...) as T` is corrupted once
embedded in a struct crossing a function-return boundary. Worked around by
forcing fresh text with `.. ""` on every field copied out of a decoded
record (`idempotent.wo`'s replay path).
- Under concurrent `call()`-parked callers doing real per-request table I/O,
`main()` returns cleanly but the OS process sometimes hangs (~1-in-5); an
aggressive variant produced a segfault. Reproduces more readily at higher
sequential insert+delete volume against the same key (N=4/5 crashed; N=1–3
clean over 12+ trials). Both `idempotent-check`'s own SIGTERM leg (§18) and
the new saturation leg (§19) — the sharpest reproducer yet, by design — hit
this; both contain it with an unconditional `kill -9` fallback rather than
asserting graceful shutdown, so it cannot flake a leg whose actual subject
is something else. Root-causing this is C-runtime work, out of scope here.
**2026-08-29 — Phases B and C superseded before review.** Both were built
against the original framing and both store the response *after* the handler
returns. Three of the seven original criteria cannot hold in that shape, which
is why this is a rebuild rather than a patch:
- **In-flight collision was undetectable.** `before` finds nothing and passes;
the row appears in `after`, once the handler has already run. Concurrent
duplicates both miss and both execute — there is no reservation to collide
on.
- **The 10-second in-flight heuristic was inverted.** The timestamp is stamped
when the response is *stored*, not when the request *starts*, so it fired on
legitimate fast replays — the common case — answering 409 where the stored
response was owed, and could never fire on a genuinely concurrent request.
- **"Reused key, different body is refused" was unreachable.** The body digest
was folded into the lookup key, so a different body was a different key and
simply missed. Safe — the wrong response is never served — but nothing looks
the bare key up, so nothing can refuse. The digest becomes a column.
Two defects independent of the redesign, both since fixed or scheduled: the
limiter emitted a monotonic tick into `X-RateLimit-Reset`, where a client
expects a Unix timestamp; and it used delete-then-insert where assigning to a
row field writes through (`compiler/src/emit.ml`), which doubled WAL traffic
and left a window where a failed insert after a successful delete silently lost
the counter — handing out a free window, the exact inverse of the durability
this iteration exists to demonstrate.