diff --git a/docs/examples/porch/README.md b/docs/examples/porch/README.md index 1fb7ee3..17ef1fe 100644 --- a/docs/examples/porch/README.md +++ b/docs/examples/porch/README.md @@ -152,6 +152,8 @@ first (pure `.wo` cannot express it yet). | Item | State | | --- | --- | +| Rate limiting (fixed window, durable) | πŸ”Ά tables + a first limiter exist (`middleware/store.wo`, `limiter.wo`); the counting path is being rebuilt to serialize through an actor pool, because read-modify-write from a handler fiber loses increments β€” [porch 1](../../stories/porch/01-store-backed-middleware.md) | +| Idempotent replay of unsafe requests | πŸ”Ά tables + a first middleware exist (`middleware/idempotent.wo`); being rebuilt to block on the in-flight owner rather than store-after-completion, which cannot detect a collision at all β€” [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) | | Migration generation + review workflow | ⬜ recorded future story (script-based destructive migrations) | diff --git a/docs/stories/00-status.md b/docs/stories/00-status.md index 419a18b..1a24236 100644 --- a/docs/stories/00-status.md +++ b/docs/stories/00-status.md @@ -67,6 +67,55 @@ behind this board; live Obsidian Dataview views: ## β–Ά NEXT PLAN +### Landed 2026-08-29 β€” databasev2 2 tasks 5c/5d, and a branch consolidation + +**Implemented last time (2026-08-29):** `resident: keys` storage and every read +path. Rows are stored keys-only β€” the payload dropped after the WAL barrier, +the id map holding a log offset instead of a slab slot β€” and read back through +a borrow/release accessor. The scans go through a row iterator that walks the +id map for a keys table and the bitmap for a resident one, deliberately, since +hash order would reorder every unordered query. + +**The obligation databasev2 3 left at the compactor is discharged**, and it +caught more than it predicted. The recorded hazard was that compaction moves +records and invalidates stored offsets. True β€” but the compactor walked the +slab *bitmap*, which a keys-resident row has no bit in, so every such row would +have been omitted from the new log outright. Silent data loss, not a bad +pointer, and rebuilding offsets would never have caught it. Records are now +moved byte-for-byte and re-pointed as they land. + +**What is NOT done, and why the annotation is still refused:** updating a +keys-resident row. It lives in the log with no slab slot to mutate, so writing +into the borrow's scratch would discard the write *silently* β€” the one failure +this iteration must not ship. It needs read-modify-append. `wo_row_update_field` +and its slot variant refuse explicitly, and the loader still rejects the +annotation, with its message corrected to say so. + +**Also this session:** every branch consolidated to `dev` and `master` only. +Three could not be replayed and are preserved as annotated tags rather than +merged or discarded β€” `archive/cleanup-pre-existing-changes` carries the Rust +runtime master deleted, and `archive/ipc-attach` + `archive/keypair-auth` +refactor the same row-API functions `db2-keys` rewrote. That last one is not a +merge conflict but an integration task: iteration 9c transfers ownership of +`vals` on failure, while the keys-resident arm returns early without freeing, so +a merge that compiles and passes could still leak or double-free. + +**porch 1 brainstormed and specced.** Phases B and C are superseded before +review β€” both store the response after the handler returns, which cannot detect +an in-flight collision at all. Design settled: serialize through a sharded actor +pool, persist in a `@table`. Found and fixed en route: `docs/examples/porch/` +did not typecheck at all, for want of a `use json`. + +**.dev / reference projects used:** the PostgreSQL checkpoint study +(`docs/plan/exploration/postgresql/buffer-and-checkpoint.md`) for the compaction +shape; the Fiber parity study for porch 1's scope. + +**Dependencies unblocked:** nothing was blocked. databasev2 2's remaining tasks +6 and 7 depend only on iteration 2. + +**Next steps:** databasev2 2 task 6 (the two runtime refusals) and task 7 +(measure, gate, close out), then the porch 1 implementation plan. + ### Landed 2026-08-29 β€” databasev2 3, WAL checkpoint (the chain's last link) **Implemented last time (2026-08-29):** compaction. The log used to grow forever diff --git a/docs/stories/databasev2/00-story.md b/docs/stories/databasev2/00-story.md index bae4cb5..a5a59ab 100644 --- a/docs/stories/databasev2/00-story.md +++ b/docs/stories/databasev2/00-story.md @@ -162,10 +162,17 @@ before its mechanism existed; the history is in | 10 | [Keypair attach auth](10-keypair-attach-auth.md) *(was language 21)* | program identity as a keypair; mutual challenge–response | 9 | ``` -1 ──▢ 2 ──▢ 5 ──▢ 6 - β”‚ β–² - 3 ──▢ 4 β”€β”€β”€β”€β”€β”˜ -7, 8 independent +An arrow points AT the iteration that NEEDS the other. + +1 ──▢ 2 ◀── 3 2 needs 1 (budget from a measurement) and 3 (the + β”‚ offset map survives compaction). Since 5d, 3 also + β–Ό calls 2's row API β€” the coupling runs both ways. + 5 5 needs 2. Nothing needs 5. + +4 composes with 3 on the WAL commit path; NEITHER + needs the other. Executed 4 then 3 (chain 5, then 6). +6 superseded by 2 β€” not sequenced. +7, 8 independent. 9 ──▢ 10 ``` @@ -179,6 +186,21 @@ after 3, 4 and 5. `resident: keys` took that role into iteration 2, so 6 is largely superseded and 5 is no longer a prerequisite for anything on the critical path. +**Corrected 2026-08-29 β€” the graph above used to say the opposite of this +prose.** It drew `2 ──▢ 3 ──▢ 4`, which reads as 3 needing 2 and 4 needing 3. +Both are backwards. 2 needs 3 (the offset map), and the execution order that +actually happened is **4 before 3** β€” 4's part A landed 2026-08-28, 3 landed +2026-08-29, which is also what the `chain` field says (4 is chain 5, 3 is +chain 6). The retired `2 ──▢ 5 ──▢ 6` path was still drawn as well. Arrows now +point at the dependency, not at the reader's guess. + +**The coupling between 2 and 3 runs both ways as of 5d.** 3's compactor calls +2's row API β€” the iterator, the offset accessor and its setter β€” because +compaction moves every record and must re-point the map it invalidates. That +was the hazard 3 recorded; it is now discharged, and it means compaction is not +a pure file operation. Full review in +[`00-databasev2-chain-review.md`](../../00-databasev2-chain-review.md). + ## What this track does NOT own | Not databasev2's | Owner | diff --git a/docs/stories/databasev2/03-wal-checkpoint.md b/docs/stories/databasev2/03-wal-checkpoint.md index f8b10f4..8c8f827 100644 --- a/docs/stories/databasev2/03-wal-checkpoint.md +++ b/docs/stories/databasev2/03-wal-checkpoint.md @@ -58,12 +58,13 @@ chain: 6 > none; stop-the-world, with the pause measured against a stated budget rather > than assumed acceptable. > -> **The coupling that would otherwise be found late:** compaction moves every -> record, so it **invalidates every WAL offset** +> **The coupling that would otherwise be found late β€” and was found in time:** +> compaction moves every record, so it **invalidates every WAL offset** > [iteration 2](02-table-storage-modes.md)'s `resident: keys` stores. The -> compactor rebuilds the offset map as it writes. Recorded now because iteration -> 2's storage half is unimplemented, so nothing breaks today β€” it would break -> later, looking like corruption rather than a design gap. +> compactor rebuilds the offset map as it writes. Recorded here while iteration +> 2's storage half was still unimplemented; **it landed 2026-08-29 and the +> obligation was discharged** (`f606fc9`), including a worse failure this note +> did not predict β€” see the hazard section at the end. > > **Measured on master 2026-08-28, grounding the whole iteration:** `seed 20000` > leaves a 986 614-byte log; 20 000 updates take it to **2 590 262 bytes with the @@ -126,12 +127,11 @@ Met: Outstanding: -- **The `resident: keys` offset map.** Compaction moves every record, so it - invalidates every WAL offset [iteration 2](02-table-storage-modes.md) stores. - The compactor must rebuild that map as it writes. **Nothing fails today** - because iteration 2's storage half is unimplemented β€” which is exactly why the - obligation is written at the compactor in `wal.c`, where the next implementer - hits it, rather than only in a spec they may not read. +- ~~**The `resident: keys` offset map.**~~ **Discharged 2026-08-29 by + iteration 2's task 5d** (`f606fc9`). The obligation written at the compactor + in `wal.c` did its job: the implementer hit it there. See the hazard section + below for what it caught β€” and for the second, worse failure it did not + predict. - **The pause is O(live rows).** At ~181 MB/s a 1 GB live set implies ~5.5 s, past any interactive budget. Incremental or forked copying was deliberately not bought in advance; this is the number to buy it against. @@ -242,3 +242,30 @@ the snapshot persists the map and compaction is forbidden while any `resident: keys` table is live. **The first is almost certainly right**, but it means compaction cannot be written as a pure file operation that ignores in-memory table state. + +### Settled 2026-08-29 β€” and the hazard was only half the danger + +The first shape was implemented, in iteration 2's task 5d (`f606fc9`). +Compaction re-points each row as it writes it, using a value-only map update +that cannot rehash, so a walk in progress stays valid and no per-row buffer of +new offsets is needed. Compaction is therefore **not** a pure file operation, +exactly as predicted above. + +**What this section did not predict is the failure that would actually have +struck first.** It described stored offsets going stale β€” a pointer into a +rewritten file. But the compactor walked the slab **bitmap**, and a +keys-resident row holds no bitmap bit: its slot returns to the free list when +the payload is dropped. Every such row would therefore have been omitted from +the new log altogether. That is silent data loss, not a bad pointer, and +rebuilding offsets would never have caught it β€” the rows would simply have been +gone. + +Both failure modes are now pinned by `test_keys_resident_survives_compaction`, +which rewrites rows in hash order so the offsets genuinely move; a map left +un-repointed lands on another row and fails the identity check rather than +passing by luck. + +A failure *after* any row has been re-pointed is fatal by design: the map would +name offsets inside a temp file that the failure path unlinks, and the intact +original log replays correctly, so stopping is strictly better than serving +wrong rows. diff --git a/docs/stories/databasev2/06-cold-tiering.md b/docs/stories/databasev2/06-cold-tiering.md index e45e7f0..1047ccc 100644 --- a/docs/stories/databasev2/06-cold-tiering.md +++ b/docs/stories/databasev2/06-cold-tiering.md @@ -1,7 +1,7 @@ --- track: databasev2 iteration: "6" -status: pending +status: hold readiness: refine --- @@ -16,7 +16,8 @@ readiness: refine > This iteration was written to implement a `cold` mode. That mode no longer > exists: the brainstorm replaced it with `resident: all | keys`, and > **`resident: keys` is the ceiling-raising mechanism** β€” indexes resident, rows -> read from the log by offset. It is iteration 2's tasks 5c/5d, not this file's. +> read from the log by offset. It is iteration 2's tasks 5c/5d β€” which landed +> 2026-08-29 β€” not this file's. > > Its premise was also specifically *rejected*, not merely relocated. This > iteration assumed a **user-space resident working set** with faulting and an @@ -26,7 +27,8 @@ readiness: refine > position `exploration/postgresql/buffer-and-checkpoint.md` already argued and > the reason the engine avoids `O_DIRECT`. > -> **What may still be left:** if measurement after 5c/5d shows the page cache +> **What may still be left:** if measurement after 5c/5d (landed 2026-08-29, +> still unmeasured β€” that is iteration 2's task 7) shows the page cache > insufficient for some workload, a user-space working set becomes arguable > again β€” but only with that number in hand, which is the opposite of how this > file was written. diff --git a/docs/stories/porch/00-story.md b/docs/stories/porch/00-story.md index 5e7a4d6..9dee1f8 100644 --- a/docs/stories/porch/00-story.md +++ b/docs/stories/porch/00-story.md @@ -49,7 +49,7 @@ risky work starts. | # | Iteration | Delivers | Needs | | --- | --- | --- | --- | -| 1 | [Store-backed middleware](01-store-backed-middleware.md) | rate limiting + idempotency over a `@table` store | nothing new β€” starts today | +| 1 | [Store-backed middleware](01-store-backed-middleware.md) | rate limiting + idempotency over a `@table` store, serialized through a sharded actor pool | πŸ”„ in progress; needs no new primitive (`call`/`send`/`monitor`/`time.after` all landed) | | 2 | [Randomness and cookies](02-randomness-and-cookies.md) | a `random_bytes` runtime builtin, repeated response headers, `Cookie:` parsing, signed cookies | a language-track builtin (phase A) | | 3 | [Sessions](03-sessions.md) | server-side sessions, idle + absolute timeout, revocation | 2 | | 4 | [CSRF](04-csrf.md) | token mint/verify, trusted origins, single-use tokens | 2, 3 | diff --git a/docs/stories/porch/01-store-backed-middleware.md b/docs/stories/porch/01-store-backed-middleware.md index 3b892b1..bec35a7 100644 --- a/docs/stories/porch/01-store-backed-middleware.md +++ b/docs/stories/porch/01-store-backed-middleware.md @@ -1,14 +1,22 @@ --- track: porch iteration: "1" -status: pending -readiness: refine +status: in-progress +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 @@ -31,6 +39,35 @@ readiness: refine - **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. **Outstanding**: the request-digest column the refusal criterion needs | +| B β€” rate limiter | ⚠️ **superseded** β€” `5b1e82a` built the store-after shape; see History | +| C β€” idempotency | ⚠️ **superseded** β€” `aee7926` built the same shape; `5c3544d` fixed its missing `use json`, which had made the whole porch library uncompilable | +| D β€” gate and ledger | ⬜ not started; the restart leg is the one that must not be skipped | + ## Phases ### Phase A β€” the store convention @@ -101,8 +138,20 @@ readiness: refine arrives, **then** it is refused rather than answered with the other request's response. - **Given** two identical keyed requests in flight at once, **when** both are - dispatched, **then** exactly one executes and the other gets the decided - answer (replay or 409), never a partial write. + 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.)* +- **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.)* +- **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.)* + +None of these are met yet β€” Phase D owns the gate, and B and C are being +rebuilt. The concurrency legs need genuine parallelism: a test that cannot +fail before the fix is not a test. ## Out Of Scope @@ -117,15 +166,23 @@ readiness: refine - **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.** No timer exists (`time.after` is still a - reserved builtin id in `wob.h`). Lazy pruning on access, deliberately. +- **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 -Forks the spec must settle: +**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 @@ -142,4 +199,50 @@ Forks the spec must settle: cookies yet (iteration 2), so this is cheap to decide now and expensive to retrofit later. -Nothing here needs a new runtime primitive, which is the point of going first. +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-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. diff --git a/runtime/src/loader.c b/runtime/src/loader.c index 5b7dbd1..41a3e6f 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -186,13 +186,19 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, /* databasev2 2: `resident: keys` PARSES and sets this bit, but the * storage half (tasks 5c/5d) is not implemented β€” rows are still fully * resident. Accepting it would be an annotation the compiler honours - * in name only: a developer could declare a 120 GB table keys-resident, - * see it compile, and be OOM-killed. Refuse until the storage lands. */ + * in name only. + * + * Narrowed 2026-08-29 (databasev2 2, 5c/5d): storage, boot, the read + * paths and checkpoint survival all landed. What has NOT landed is + * UPDATE β€” a keys-resident row lives in the log with no slab slot to + * mutate, so an update needs read-modify-append. Until that exists the + * annotation is still refused, because a table you can insert into and + * read but not update is a worse promise than one that never compiled. */ if (flags & WO_CLASSF_RESIDENT_KEYS) - BAIL("class %u declares `resident: keys`, which is NOT IMPLEMENTED " - "yet β€” rows are still fully resident, so the annotation would " - "be honoured in name only. Remove it until databasev2 2 tasks " - "5c/5d land; `resident: all` is what actually runs", + BAIL("class %u declares `resident: keys`, which is INCOMPLETE: rows " + "are stored and read keys-only, but UPDATING one is not " + "implemented (it needs read-modify-append). Remove it until " + "databasev2 2 lands updates; `resident: all` is what runs", (unsigned)i); if ((flags & WO_CLASSF_VOLATILE) && (flags & WO_CLASSF_RESIDENT_KEYS)) BAIL("class %u: durable:false with resident:keys β€” rows would have "