- developer move; all inbound links repointed (root docs, plan/, plan/compiler/, exploration, superpowers plans+specs, in-progress marker), board's own links re-based one level deeper - prose mentions inside landed plans left as historical records Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
351 lines
18 KiB
Markdown
351 lines
18 KiB
Markdown
# Iteration 18 — framework v2 (transaction{} + cache/flags/jobs): implementation plan
|
||
|
||
> **Status: ⏸ hold (2026-08-21, developer decision)** — story iteration 18
|
||
> sits in `stories/language-runtime-database/hold/`; plan was ready to
|
||
> execute (2026-08-20) and stays intact for resumption. Board:
|
||
> [docs/00-status.md](../../stories/00-status.md).
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
||
> superpowers:subagent-driven-development (recommended) or
|
||
> superpowers:executing-plans to implement this plan task-by-task. Steps
|
||
> use checkbox (`- [ ]`) syntax for tracking.
|
||
>
|
||
> **Style rule (user convention):** concept, reason, and required behavior
|
||
> in words plus verification commands only — no implementation or test
|
||
> code blocks; the executor writes the code.
|
||
|
||
**Goal:** `transaction { }` makes multi-statement writes one WAL commit
|
||
(trap = abort), and three framework pieces ride the single process:
|
||
a TTL cache, `@table` feature flags with a cached read, and a durable
|
||
`@table` job queue drained in-process — the web-app proves the headline
|
||
(order + job enqueue, ONE commit, SIGKILL-survivable).
|
||
|
||
**Architecture:** the engine already stages WAL batches
|
||
(`wal_append_*` → `wo_wal_commit`, per-statement today in
|
||
`database/src/db.c`'s three write cases); a transaction defers the commit
|
||
and keeps an undo log for RAM. The VM learns two internal builtins and a
|
||
transaction-flagged catch frame; the parser one keyword. Cache/flags/jobs
|
||
are pure `.wo` in the framework plus one serve-loop seam. No new opcodes,
|
||
no `.wob` version bump, GC untouched.
|
||
|
||
**Tech Stack:** C11 libc-only (engine + VM), OCaml stdlib-only (`woc`),
|
||
pure `.wo` (framework), bash gates.
|
||
|
||
**Spec:** [`../specs/2026-08-20-memory-db-features-design.md`](../specs/2026-08-20-memory-db-features-design.md)
|
||
(approved 2026-08-20, normative). Story:
|
||
[`18-memory-db-features.md`](../../stories/language-runtime-database/hold/18-memory-db-features.md).
|
||
|
||
## Global Constraints
|
||
|
||
- Branch `framework-v2` off master; commits local only, never push.
|
||
- Exit/trap doctrine unchanged: WO-E110 nested `transaction` (compile),
|
||
WO_T_DB "nested transaction" (dynamic, through a call); commit failure
|
||
is the existing WO_T_IO shape with RAM never ahead of disk.
|
||
- Gates that must stay green after every task: `just woc-test`,
|
||
`just oop-e2e`, `just deps-accept`, `just web-app`, `just log-watcher`,
|
||
`just employee` — plus the oop-accept ASan clause for anything the VM
|
||
touches.
|
||
- Framework tables carry the `wf_` prefix; policy stays app-side.
|
||
- `time.now` is wall-clock MILLISECONDS everywhere in this iteration.
|
||
|
||
## Spec deviations, disclosed up front
|
||
|
||
1. **`return`/`break`/`continue` crossing a `transaction { }` boundary is
|
||
rejected at compile time (new WO-E112).** The spec is silent on early
|
||
exit; commit-on-return vs abort-on-return is exactly the ambiguity a
|
||
v1 must not guess at. A later iteration may define it; today the block
|
||
has one entrance and one exit (a TRAP still aborts — that path is
|
||
defined).
|
||
2. **The cache exposes a time-injected seam** (`get_at`/`put_at` taking a
|
||
now-milliseconds argument, with `get`/`put` sugar reading `time.now`)
|
||
so the corpus fixture injects stamps instead of sleeping — the spec's
|
||
"stamps injected, not waited" made concrete.
|
||
3. **Flags are gate-proven, not corpus-proven**: a `@table` needs
|
||
`WO_DATA` and a persistent directory, which the corpus harness does
|
||
not provide; `just web-app` carries the flags checks (the spec's gate
|
||
section already put them there).
|
||
4. **The WAL gains an explicit staged-batch discard** (`wo_wal_abort`):
|
||
today a failed commit "stays staged" by contract; abort needs to drop
|
||
the batch deliberately. Same file, same batch machinery, new entry
|
||
point.
|
||
|
||
---
|
||
|
||
## Task 1 — engine transactions: defer commit, undo log, abort
|
||
|
||
**Files:**
|
||
- Modify: `database/src/db.h` (txn state on `wo_db`, three new entry
|
||
points), `database/src/db.c` (the three write cases ~lines 17–80),
|
||
`database/src/wal.h` + `wal.c` (`wo_wal_abort`),
|
||
`database/src/table.h` + `table.c` only if the replay-only fixed-id
|
||
create/index pair needs a non-static wrapper.
|
||
- Test: `runtime/test/test_txn.c` (new, mirroring the existing
|
||
`test_*.c` harness shape), wired into the runtime test recipe.
|
||
|
||
**Interfaces:**
|
||
- Produces: `wo_db_txn_begin(db)` (0 ok; nonzero = already open — the
|
||
dynamic-nesting signal), `wo_db_txn_commit(db, wal)` (the ONE
|
||
`wo_wal_commit`; on failure the undo walk runs and the error returns),
|
||
`wo_db_txn_abort(db, wal)` (reverse undo walk + `wo_wal_abort`).
|
||
Task 3's builtins call exactly these three.
|
||
|
||
- [ ] `wo_db` grows a transaction flag and an undo log (a growable array
|
||
of entries: op kind, class id, row id, and for update/delete a copy of
|
||
the row's bytes — `wo_row_ptr` + the class's `row_size` are the copy;
|
||
the log exists only while a transaction is open, zero cost otherwise).
|
||
- [ ] The three write cases in `wo_builtin_db`: when the flag is set,
|
||
record the undo entry BEFORE the RAM apply (update/delete pre-images;
|
||
insert records just the new id AFTER apply), still `wal_append_*`, and
|
||
SKIP the per-statement `wo_wal_commit`. Flag clear = byte-identical
|
||
behavior to today (every existing gate is the proof).
|
||
- [ ] Statement-level failures inside a transaction change nothing: a
|
||
unique violation traps before apply and stages nothing (already true —
|
||
`wo_row_insert` refuses first); a failed append keeps the pre-existing
|
||
error shape.
|
||
- [ ] Abort walks the undo log in REVERSE: inserted row → removed;
|
||
updated row → bytes restored and index entries fixed the replay way
|
||
(remove + re-add through the same engine-internal pair `wal.c` replay
|
||
uses); deleted row → re-created with its FIXED id and re-indexed (the
|
||
replay-only create), then `wo_wal_abort` discards the staged batch.
|
||
- [ ] Reads inside a transaction need no change: RAM stays applied, so
|
||
scans/point-reads see the block's own writes for free.
|
||
- [ ] `test_txn.c`: begin→insert+insert→commit = both rows + ONE wal
|
||
flush; begin→insert→abort = zero rows, next insert works; update and
|
||
delete pre-images restored on abort (indexed column included);
|
||
begin-while-open refused; abort with an empty log is a no-op.
|
||
- [ ] Run the runtime test suite + `just employee` (engine untouched when
|
||
no txn opens). Commit.
|
||
|
||
## Task 2 — the language surface: keyword, block, WO-E110/E112
|
||
|
||
**Files:**
|
||
- Modify: `compiler/src/token.ml` + `lexer.ml` (KwTransaction),
|
||
`compiler/src/ast.ml` (a Transaction statement holding a body),
|
||
`compiler/src/parser.ml` (block statement + LEXICAL nesting =
|
||
WO-E110), `compiler/src/types.ml` (walk the body; E-code constants),
|
||
`compiler/src/owner.ml` (treat as a plain nested scope),
|
||
`compiler/src/dump.ml` (labels).
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing new.
|
||
- Produces: the AST node Task 3 lowers; WO-E110 (parsing prefix, nested
|
||
block), WO-E112 (parsing prefix, `return`/`break`/`continue` whose
|
||
jump would cross the block boundary — a loop wholly INSIDE the block
|
||
keeps its own break/continue).
|
||
|
||
- [ ] Keyword + statement parse; the body is an ordinary statement list.
|
||
A `transaction` token while one is already open (parser-tracked depth)
|
||
is WO-E110 at the inner keyword.
|
||
- [ ] WO-E112: while parsing the block, a `return` at any depth, or a
|
||
`break`/`continue` not enclosed by a loop that itself started inside
|
||
the block, names the rule ("a transaction has one exit; lift the
|
||
return out or end the block first").
|
||
- [ ] Types/owner: the body checks exactly like a bare block — no new
|
||
typing rule (the ownership pass sees a scope; values born inside drop
|
||
inside, exactly as today).
|
||
- [ ] Compile-fail fixtures: `transaction-nested` (WO-E110),
|
||
`transaction-early-return` (WO-E112). Verify both + `just woc-test`
|
||
(dump labels) + `just oop-e2e`. Commit.
|
||
|
||
## Task 3 — VM lowering: internal builtins + the abort-on-unwind frame
|
||
|
||
**Files:**
|
||
- Modify: `runtime/src/wob.h` (two builtin ids in the internal range),
|
||
`runtime/src/builtin.c` (dispatch to Task 1's three entry points),
|
||
`runtime/src/vm.c` (transaction-flagged catch frame; the vm_trap walk;
|
||
unwind/rt-destroy cleanup), `compiler/src/emit.ml` (lower the
|
||
Transaction statement), `runtime/test/test_unwind.c` (frame cleanup on
|
||
a trap that leaves the whole method).
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1's `wo_db_txn_begin/commit/abort`, Task 2's AST node.
|
||
- Produces: the observable spec semantics — one fdatasync at the closing
|
||
brace; trap unwinding OUT aborts and keeps unwinding; `try` INSIDE the
|
||
block keeps it alive.
|
||
|
||
- [ ] Two builtin ids the emitter emits directly from the Transaction
|
||
case (no name in any user-callable table — nothing to collide with):
|
||
begin pushes a TRANSACTION-FLAGGED catch frame (the existing TRY frame
|
||
machinery with one flag bit) and calls txn_begin (nonzero = the
|
||
WO_T_DB "nested transaction" trap); end calls txn_commit FIRST and
|
||
pops the frame only on success — a commit failure traps with the frame
|
||
still in place, so the abort path below runs and RAM is rolled back
|
||
(the "RAM never ahead of disk" rule, transaction-sized).
|
||
- [ ] vm_trap's handler search: a transaction-flagged frame is not a
|
||
handler — abort the transaction, pop it, CONTINUE searching. An inner
|
||
`try` frame sits ABOVE it and catches first (the spec's
|
||
inner-try-keeps-it-alive rule falls out of frame order, no special
|
||
case).
|
||
- [ ] Program exit / rt teardown with a transaction somehow open (a trap
|
||
that reaches main uncaught) must abort, not leak the undo log.
|
||
- [ ] Emitter: begin, body statements, end — plus the WO-E112 guarantee
|
||
from Task 2 meaning no jump ever leaves the region except a trap.
|
||
- [ ] Corpus: `run/transaction-commit` (two inserts, both rows readable
|
||
after — needs the trap corpus's WO_DATA-less shape? No: @table without
|
||
WO_DATA runs RAM-only with no WAL, which still exercises begin/commit
|
||
frames; the DURABILITY half lives in Task 6's gate where WO_DATA
|
||
exists), `run/transaction-abort` (second insert unique-traps, caught
|
||
OUTSIDE the block: first row gone too, inserts after the abort work,
|
||
process exits clean under ASan).
|
||
- [ ] `just oop-e2e` (ASan stage covers the new frames) + full battery.
|
||
Commit.
|
||
|
||
## Task 4 — framework cache: TTL + capacity, pure `.wo`
|
||
|
||
**Files:**
|
||
- Create: `docs/examples/writeonce-framework/store/cache.wo`.
|
||
- Test: `tests/corpus/run/cache-ttl/` (fixture copies the class inline —
|
||
corpus fixtures cannot `use` the framework; the framework file is the
|
||
same code verified by the framework's standalone compile + Task 7's
|
||
consumer build).
|
||
|
||
**Interfaces:**
|
||
- Produces: `pub class Cache { ttl_ms: Int, cap: Int, keys: multi Text,
|
||
vals: map<Text, Text>, stamps: map<Text, Int> }` with `get_at(now,
|
||
key) -> ?Text`, `put_at(now, key, value)`, and `get`/`put` sugar over
|
||
`time.now` — the shape apps hold as a field on any long-lived
|
||
instance.
|
||
|
||
- [ ] `get_at`: absent → nil; older than `ttl_ms` → remove the entry
|
||
(lazy expiry — there are no timers by design) and answer nil; live →
|
||
the value (caller-owned copy).
|
||
- [ ] `put_at`: store + stamp; when the key list exceeds `cap`, evict
|
||
OLDEST-INSERTED until within capacity (FIFO — the file states the
|
||
LRU tradeoff the spec settled). Re-putting an existing key refreshes
|
||
value + stamp without duplicating the key entry.
|
||
- [ ] Values are Text; the file says "json.encode structure into it" —
|
||
no generics exist, stated, not apologized for.
|
||
- [ ] Fixture drives injected stamps: fresh hit, expiry at exactly
|
||
ttl+1, eviction order under cap pressure, re-put refresh; ASan run.
|
||
- [ ] Framework standalone compile stays clean. Commit.
|
||
|
||
## Task 5 — framework flags: wf_flags + cached read-through
|
||
|
||
**Files:**
|
||
- Create: `docs/examples/writeonce-framework/store/flags.wo`.
|
||
|
||
**Interfaces:**
|
||
- Produces: `@table(name: "wf_flags")` class `Flag { name: Text @unique,
|
||
on: Int }` (Int 0/1 — Bool columns are unproven storage, spec's call)
|
||
and `pub class Flags { loaded: Int, cache: map<Text, Int> }` with
|
||
`read(name, default: Bool) -> Bool` and `set(name, on: Bool)`.
|
||
|
||
- [ ] `read`: first call fills the map from the table (query by name —
|
||
the employee-proven point-read), later calls answer from the map;
|
||
absent flag → the default, uncached (so a later `set` is seen).
|
||
- [ ] `set`: update-or-insert the row, then update the map in the same
|
||
call — single process, invalidation is an assignment. Durability is
|
||
the table's (WAL), restart rebuilds via `read`.
|
||
- [ ] Framework standalone compile; behavior proven in Task 7's gate
|
||
(deviation 3). Commit.
|
||
|
||
## Task 6 — framework jobs: wf_jobs, enqueue, JobRunner, the idle seam
|
||
|
||
**Files:**
|
||
- Create: `docs/examples/writeonce-framework/store/jobs.wo`.
|
||
- Modify: `docs/examples/writeonce-framework/http/serve.wo` (Dispatcher
|
||
gains `fn idle()`; the serve loop calls it after `net.accept`, BEFORE
|
||
parsing the connection's first request), `app.wo` (`App` satisfies
|
||
`idle`; `jobs(take r: Jr, budget: Int)` registration; `Jr { r:
|
||
JobRunner }` wrapper, the Mw/Route pattern), `README.md` (the drain
|
||
contract + the idle-server-drains-nothing disclosure).
|
||
|
||
**Interfaces:**
|
||
- Consumes: `transaction { }` (Tasks 1–3) only in the DEMO — enqueue
|
||
itself is an ordinary insert, composition happens in app code.
|
||
- Produces: `@table(name: "wf_jobs")` class `Job { kind: Text, payload:
|
||
Text, attempts: Int, not_before: Int }`; `pub fn enqueue(kind,
|
||
payload)`; `pub interface JobRunner { fn run(kind: Text, payload:
|
||
Text) -> Bool }`; `App.jobs(take r, budget)`; `Dispatcher.idle()`.
|
||
|
||
- [ ] Drain (in `App.idle`): no runner registered → return immediately.
|
||
Else query up to `budget` due jobs (`not_before <= time.now`,
|
||
registration order via `take`), each inside `try`: true → `delete`
|
||
the row; false or trap → `attempts + 1` (update), row stays —
|
||
retry/backoff policy is the app's (it can rewrite `not_before` from
|
||
its own runner).
|
||
- [ ] The post-accept/pre-parse placement is the DETERMINISM the gate
|
||
needs: a job enqueued by connection A never runs before A closes, and
|
||
a kill after A's response provably leaves the row. Latency cost
|
||
(≤ budget jobs ahead of the next request) stated in the README.
|
||
- [ ] Framework standalone compile; the serve loop's existing gates
|
||
(`just web-app` current count) stay green with NO runner registered —
|
||
the seam must cost nothing. Commit.
|
||
|
||
## Task 7 — the web-app demo + the gate
|
||
|
||
**Files:**
|
||
- Modify: `docs/examples/web-app/main.wo` (transactional CreateOrder +
|
||
confirm runner + `GET /jobs` count + `POST /flags/:name` + the
|
||
flag-gated header on the product list), `types.wo` (nothing — wf_
|
||
tables come from the framework), `README.md`,
|
||
`scripts/web-app-accept.sh`.
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything above, through `[deps]` exactly like every other
|
||
framework feature.
|
||
|
||
- [ ] `CreateOrder.handle`: `transaction { insert Order {...};
|
||
enqueue("confirm", <order json>); }` — the headline composition, one
|
||
commit. A `Confirm` runner class answers `confirm` by printing the
|
||
order-confirmation line to stderr and returning true; registered via
|
||
`app.jobs(Jr { r: Confirm {...} }, budget)`.
|
||
- [ ] `GET /jobs` (behind the existing bearer auth): pending count as
|
||
JSON. `POST /flags/:name`: flips through `Flags.set`; the product
|
||
list answers an extra header (e.g. `x-store-banner`) while the flag
|
||
is on.
|
||
- [ ] Gate additions, in order: (a) `POST /orders` 201, then `kill -9`
|
||
the server IMMEDIATELY (no further requests), restart on the same
|
||
`WO_DATA`, then `GET /jobs` — the confirmation line appears in the
|
||
restarted server's log (the drain ran post-accept on this very
|
||
request) and the count answers 0: the job survived the kill because
|
||
it committed WITH the order; (b) `POST /flags/banner` then
|
||
`GET /products` carries the header, restart, still carries it;
|
||
(c) the standing matrix unchanged. Counts stay dynamic in the script.
|
||
- [ ] Full battery: `just web-app`, `woc-test`, `oop-e2e`,
|
||
`deps-accept`, `log-watcher`, `employee`. Commit.
|
||
|
||
## Task 8 — docs closeout
|
||
|
||
**Files:**
|
||
- Modify: `docs/00-status.md` (row 18 ✅ with measured results; NEXT
|
||
PLAN advances to 20/21 per the order), story `18-memory-db-features.md`
|
||
(landing banner) then `git mv` into `stories/.../done/` with links
|
||
re-pathed and VERIFIED, `docs/00-dependency-graph.md` (node classes:
|
||
18 done; TPRMW unblocks), framework `README.md` (ledger rows:
|
||
storage-integration txn-per-request now buildable; the v2 pieces ✅),
|
||
`compiler/src/CODE-LOGIC.md` + `runtime/src/CODE-LOGIC.md` +
|
||
`database/src/CODE-LOGIC.md` (the txn seams, one paragraph each).
|
||
- [ ] Apply; run `just web-app` once more after doc edits; commit.
|
||
|
||
## Success criteria (spec, restated as the gate reads them)
|
||
|
||
1. Two inserts in one block: SIGKILL before the next request → both rows
|
||
after restart (gate a); a trap unwinding out → neither row and the
|
||
process keeps serving (`run/transaction-abort` + ASan).
|
||
2. The order's job runs after the NEXT accepted connection within
|
||
budget, never before the posting connection closes, and survives a
|
||
kill in between (gate a).
|
||
3. An expired or evicted cache entry answers nil with no timer having
|
||
existed (`run/cache-ttl`, stamps injected).
|
||
4. A flipped flag holds across restart (gate b). Every standing gate
|
||
green; opcode set and `.wob` format byte-identical.
|
||
|
||
## Self-review notes
|
||
|
||
- Spec coverage: Part A semantics → Tasks 1–3 (observable rules mapped
|
||
one-to-one; the early-exit hole closed by deviation 1); cache → T4;
|
||
flags → T5; jobs + seam → T6; demo + gate → T7; out-of-scope list
|
||
untouched. Corpus/gate split follows deviations 2–3.
|
||
- Type consistency: the three engine entry points, the two E-codes
|
||
(E110/E112), `wf_flags`/`wf_jobs`, `get_at`/`put_at`,
|
||
`JobRunner.run(kind, payload) -> Bool`, `App.jobs(take r, budget)`,
|
||
`Dispatcher.idle()` — spelled identically in every task that names
|
||
them.
|
||
- Risk, disclosed: the abort walk's index restoration is the one place
|
||
correctness is subtle (indexed column updated then aborted); Task 1's
|
||
unit test pins exactly that case before any VM work stacks on it.
|
||
- Ordering: engine (T1) before VM (T3) with the language (T2) between so
|
||
T3 has both; cache/flags (T4/T5) are independent and could land any
|
||
time, kept after the critical path so the risky work gets the freshest
|
||
attention.
|