From 484402a1569d51c8ae02aa93dd9fb9b21bfb7f46 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sun, 30 Aug 2026 03:18:20 +0200 Subject: [PATCH] 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) (cherry picked from commit 6d48dbca98d4ea4c6d93f470ae3aad0b00acd2a1) --- docs/examples/porch/README.md | 46 +++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/examples/porch/README.md b/docs/examples/porch/README.md index 483aa43..83f8e56 100644 --- a/docs/examples/porch/README.md +++ b/docs/examples/porch/README.md @@ -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