docs(porch-store): correct the one-slot-pool advice, soften a gate-proof claim

- bullet 2 wrongly told app authors to hold a bare actor handle and
  re-wrap it as a forced ONE-slot Pool per connection -- that was my
  own advice, not the previous implementer's, and the reviewer showed
  WO-E222 fires on the class Pool, not on multi PoolSlot
- rewritten around the new pool_slots/pool_of pair: make_pool(n) once
  at process start, multi PoolSlot held directly in connection-actor
  state, a transient Pool rebuilt per use -- and states explicitly that
  calling make_pool per connection restores the lost-increment race
- disclose that the gate's own ConnWorker fixtures still build a
  deliberate one-slot Pool per leg, so no leg yet exercises real
  N-actor sharding through pool_slots/pool_of
- soften the rate-limiting row: saturation-503 is gate-proven only via
  Idempotent/pool_begin, not through Limiter's own try/catch arm

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6d48dbca98d4ea4c6d93f470ae3aad0b00acd2a1)
This commit is contained in:
shoney.arickathil 2026-08-30 03:18:20 +02:00
parent 263cf61d95
commit 484402a156

View file

@ -152,7 +152,7 @@ first (pure `.wo` cannot express it yet).
| Item | State |
| --- | --- |
| Rate limiting (fixed window, durable) | ✅ counting serializes through a per-key actor pool (`middleware/keypool.wo`, `limiter.wo`) — no handler-fiber read-modify-write left to lose an increment. Gate-proven: exact count under genuine concurrency (30 parallel requests, no lost increments), WAL-durable restart still limiting, `trust_proxy`'s peer fallback, and pool saturation failing closed (503, never a bypass) — [porch 1](../../stories/porch/01-store-backed-middleware.md) |
| Rate limiting (fixed window, durable) | ✅ counting serializes through a per-key actor pool (`middleware/keypool.wo`, `limiter.wo`) — no handler-fiber read-modify-write left to lose an increment. Gate-proven: exact count under genuine concurrency (30 parallel requests, no lost increments), WAL-durable restart still limiting, and `trust_proxy`'s peer fallback — [porch 1](../../stories/porch/01-store-backed-middleware.md). Saturation failing closed (503) shares `limiter.wo`'s own `try/catch` code path with idempotency's, but is gate-proven only via `Idempotent`/`pool_begin`'s own saturation leg in `scripts/web-app-accept.sh` — no leg drives `Limiter`'s own 503 arm directly |
| Idempotent replay of unsafe requests | ✅ the pool actor runs the route's `Handler` itself (`middleware/idempotent.wo`), so a duplicate blocks in the actor's mailbox until the owner's row commits — no in-flight heuristic, no window where a duplicate can see "nothing yet". Gate-proven: byte-identical replay, digest-mismatch refusal (422), concurrent duplicates never double-executing, a transient 5xx never replayed (solo or concurrent), ephemeral rows not leaking, and pool saturation failing closed (503) — [porch 1](../../stories/porch/01-store-backed-middleware.md) |
| Transaction-per-request middleware (commit on 2xx, roll back otherwise) | ⏸ **v2** — needs iteration 18's `transaction { }` |
| Cancellation → rollback | ⏸ arc landed; still needs v2's `transaction { }` (iteration 18) |
@ -169,25 +169,41 @@ needs to know, found in the course of building them ([porch 1](../../stories/por
not via `app.use_mw`. This was forced, not stylistic: the actor has to be
handed the route's `Handler` so it can run it inside `receive`, and only the
handler slot exposes it.
- **`Pool` cannot live in actor state or in a message.** It is demand-promoted
to "traced" and WO-E222 refuses it there. A real fiber-per-connection porch
app holds the bare `actor PoolMsg` handle in its connection-worker state and
re-wraps it as `Pool { actors: [PoolSlot { a: handle }] }` wherever a
`Limiter` or `Idempotent` needs one — see `ConnWorker` in the accept gate's
own limiter/idempotent/saturation checks (`scripts/web-app-accept.sh`).
- **`Pool` cannot live in actor state or in a message — `multi PoolSlot` can,
and that's how a real app shards across MORE than one connection.** `Pool`
is demand-promoted to "traced" the moment an app aliases it (a
`Limiter`/`Idempotent`'s own `pool: Pool` field, read on every request) and
WO-E222 refuses a traced value in actor state or a message. `PoolSlot` (and
`multi PoolSlot`) never gets pulled into that traced set on its own — an
actor holding `slots: multi PoolSlot` directly is the same shape
`docs/examples/chat/main.wo`'s `Room { members: multi Mem }` already uses
for a multi of actor handles, and it compiles and runs. Call `make_pool(n)`
**exactly ONCE, at process start** — never per connection, which would give
every connection its own actors and silently restore the lost-increment
race this whole design exists to prevent — then hand `pool_slots(pool)`
(`middleware/keypool.wo`) to every connection actor's spawn. Each
connection rebuilds a transient `Pool` via `pool_of(self.slots)` wherever a
`Limiter` or `Idempotent` needs one. Disclosure: the accept gate's own
`ConnWorker` fixtures (`scripts/web-app-accept.sh`) still build a
deliberately ONE-slot `Pool { actors: [PoolSlot { a: slot }] }` per leg —
the limiter/idempotent legs are testing other properties, and the
saturation leg wants exactly one actor to force mailbox overflow — so no
gate leg yet exercises `pool_slots`/`pool_of` sharding N actors across
connections.
- **A `call` reply is a copyable scalar only (WO-E226), and every `receive` in
the program must agree on one return type.** That is why the stored response
travels through the `@table` rather than the mailbox, and why outcome codes
are packed into an `Int` (`pool_pack`/`pool_count`/`pool_begin` in
`middleware/keypool.wo`).
- **Pool size is a capacity decision, not a default to ignore.** `make_pool(n)`
spawns `n` actors, sharded by hash of the key; a hot key's actor has a
bounded mailbox (`WO_MAILBOX`, default 1024), and once it saturates under
load every further request for that key answers 503 rather than being
served uncounted or queued indefinitely. Undersizing the pool produces more
503s under load — it does not silently let requests through uncounted, and
it does not silently overshoot the limiter's or idempotency store's
guarantees.
- **Pool size is a capacity decision made ONCE, not a default to ignore or a
knob to re-tune per connection.** `make_pool(n)` — called once, per the
bullet above — spawns `n` actors, sharded by hash of the key; a hot key's
actor has a bounded mailbox (`WO_MAILBOX`, default 1024), and once it
saturates under load every further request for that key answers 503 rather
than being served uncounted or queued indefinitely. Undersizing `n`
produces more 503s under load — it does not silently let requests through
uncounted, and it does not silently overshoot the limiter's or idempotency
store's guarantees.
### Security