- found during the pre-execution review of the plan, before any code - the claim was wrong in both spec and plan: table.c's db_val_encode builds the IN-MEMORY slot; the FILE record is a separate encoding in wal.c and has been flat since iteration 9. enc_val inlines every kind recursively with no pointer anywhere; dec_val reads it back; a record is `WO_WAL_INSERT | class_id | id | <value per field>` in the len|crc|payload|mark frame; scan_record already preads and CRC-verifies a record at an arbitrary offset - so the row encoding needs NO change, and Task 5 (a "self-contained, offset-based" rewrite billed as the iteration's substantive engineering) is DELETED, not reduced. 8 tasks -> 7, and the highest-risk task is gone - the real difficulty is where the spec never looked: wo_wal_append_insert stages into a 1 MiB buffer, so a record's final offset is unknown until flush. Threading an accurate offset back through a buffered writer — correct across partial flush, failed commit and torn tail — is now Task 5's first two steps, with a unit test that straddles a buffer boundary and a case asserting no offset is published for a record that never reached disk - dependent claims corrected: the mmap alternative's premise, the read-path bullet (now names scan_record/dec_val), and the self-review coverage table, which records the retraction rather than quietly dropping the row - root cause worth noting: reading one layer and inferring another. Second time this iteration — the first was assuming WO_HEAP_MB bounded table storage when it bounds the VM arena - no code written yet; linkcheck 0 broken / 0 anchors Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
21 KiB
databasev2 2 — table residency implementation plan
For agentic workers: REQUIRED SUB-SKILL: use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.This plan contains no code, deliberately. That is a repo rule, not an omission:
docs/plan/discarded.mdrecords "Raw code in plan documents — plans carry concept, reason, and required behavior in words; the executor writes the code", and all six preceding plans in this directory have zero code fences. Every step below names the exact file and line region to change and the behaviour required; the executor writes the code and the tests.
Goal: let a table declare what it keeps in memory, so a 120 GB table works on a 32 GB host, without changing anything for tables that fit.
Architecture: one storage engine, log-structured. The WAL already holds
every row; this plan stops discarding the payload — an in-RAM id→offset map
plus pread becomes the read path for tables that ask for it. Two optional
@table arguments, both defaulting to today's behaviour. No second engine, no
user-space row cache (the kernel page cache is the hot copy).
Tech Stack: OCaml (compiler front end, stdlib only), C11 (runtime and
engine, libc only), the existing .wob image format, tests/corpus +
compiler/test/golden for fixtures, just recipes as gates.
Spec: ../specs/2026-08-26-table-residency-design.md
Global constraints
- Defaults preserve behaviour exactly.
durable: true,resident: all. All 28 existing@tabledeclarations compile and run unchanged; no golden is reblessed. Any task that breaks this is wrong. - The resident read baseline must not regress. 1.3M ops/s, p50 1µs
(iteration 22). A measurable regression on
resident: alltables is grounds to reject the implementation, not to tune it. - Durability semantics are untouched. Ack after fsync, replay whole-or-
nothing, torn tail dropped by CRC.
resident:never changes whether an ack means durable. - Every new diagnostic is catalogued in the same commit that adds it, in
docs/plan/oop-vm/01-error-catalog.md. That catalog went stale once by doing this later; ten codes had to be back-filled on 2026-08-26. - C11 + libc only in
runtime/anddatabase/; OCaml stdlib only incompiler/. No new dependency. WOB_VERSIONmoves once, in Task 3, with the format contract (docs/plan/oop-vm/00-wob-format.md) in the same commit.- Gates that must be green at the end of every task:
just woc-test,just wovm-test,just oop-e2e,just employee,just db-actor. Tasks 5 onward addjust db-bench.
Task 1 — grammar: the two arguments exist and default correctly
Files: modify compiler/src/ast.ml (table_cfg at :521-524),
compiler/src/parser.ml (parse_table_cfg at :302-345, table_code at :128),
compiler/src/dump.ml (the @table dump line);
create goldens under compiler/test/golden/ast/.
Interfaces:
-
Produces:
Ast.table_cfggains two fields — a durability flag defaulting to true, and a residency selector defaulting to "all". Tasks 2, 3 and 4 read both offclass_info.table(compiler/src/types.ml:49). -
Extend
table_cfgatast.ml:521-524with the two fields besidetable_nameandindexes. Both carry their default in the record's initial value so an unannotated@tableis byte-identical to today. -
Add a
durablearm toparse_table_cfg's key match (parser.ml:313, besidenameandindex). Accept only thetrue/falsekeyword tokens — they already exist asKwTrue/KwFalse. Reject a second occurrence withtable_code, matching the shape of the existing "given twice" failure at:315. -
Add a
residentarm accepting the bare identifiersallandkeys, read the same way theindex:arm reads column identifiers (:322-327). Reject a second occurrence, and reject any other identifier. -
Update the unknown-argument message at
:334to list all four supported keys. Add an explicit, friendlier refusal for the retired brainstorm words —ram,cold,tiered,paged,mmap,buffer, andindexas aresidentvalue — naming the two real arguments, so vocabulary explored and rejected during design does not become folklore in user code. -
Extend the
@tableline indump.mlto print both properties, so--dump-astshows them and the goldens pin them. -
Add three AST goldens: a bare
@table, one withdurable: false, one withresident: keys. Bless once, by hand. -
Catalogue the new refusals in
docs/plan/oop-vm/01-error-catalog.mdunder WO-E102's row — same code, new causes — and update the@tableannotation row indocs/guides/language-surface.md§2 to match what the parser now accepts. -
Verify:
just woc-test— the three new goldens pass and every pre-existing golden is unchanged. If any existing golden moved, the defaults are wrong; fix the defaults, do not rebless. -
Commit. Draft:
feat(woc): @table durable/resident arguments, defaults preserve behaviour.
Task 2 — the cross-table check: a durable row may not reference a volatile one
Files: modify compiler/src/types.ml (the class/field check pass that
already resolves Ref field types against the symbol table);
create fixtures under tests/corpus/compile-fail/.
Interfaces:
-
Consumes: Task 1's two
table_cfgfields, read viaclass_info.table(types.ml:49). -
Produces: one new
WO-E2xxcode. Pick the next free number by sweeping<stage>_prefix ^ "NN"acrosscompiler/src/*.ml— note a grep for the literalWO-E2NNfinds only comments, which is how ten codes went missing before. -
In the field-checking pass, for every field whose type is a
Refto a declared table class: if the declaring table is durable and the referenced table is not, report the new code at the field's own position. The message must name both classes and say why — a persistent row cannot hold a foreign key into a table that evaporates on restart, and FK-restrict cannot save it. -
Apply the same check to a
backlinkfield, whose inverse is arefand therefore has the same hazard in the other direction. -
Leave the reverse direction legal: a volatile table may reference a durable one. The dangling case only exists when the survivor points at the casualty.
-
Catalogue the code with its message and a worked example.
-
Add two
compile-failfixtures: a durable table with arefinto a volatile one, and thebacklinktwin. Each pins the exact code. -
Add one
runfixture proving the legal direction still compiles and runs, so the check cannot be over-broad without a gate noticing. -
Verify:
just woc-test,just oop-e2e— the new fixtures pass, nothing else moved. -
Commit. Draft:
feat(woc): refuse a durable ref into a volatile table.
Task 3 — format: the class descriptor carries both properties
Files: modify runtime/src/wob.h (WOB_VERSION at :16, wo_classdesc at
:547-563), runtime/src/loader.c (descriptor parse and validation),
compiler/src/emit.ml (descriptor emission), compiler/src/disasm.ml,
docs/plan/oop-vm/00-wob-format.md.
Interfaces:
-
Consumes: Task 1's
table_cfgfields. -
Produces: two descriptor fields the engine reads in Tasks 4 and 6. Name them in
wob.hand use those exact names downstream. -
Add both properties to
wo_classdescatwob.h:547-563, in the house comment style, positioned so the struct's existing field order and any alignment assumption inwo_obj_size(:567) are preserved. -
Bump
WOB_VERSIONatwob.h:16to 7, extending the existing comment to say what v7 adds — the file's comment history is the format's changelog. -
Emit both properties from
emit.mlwhere the class descriptor is written, defaulting exactly as Task 1 does. -
Extend the loader's descriptor validation to bounds-check both fields and refuse the meaningless combination — not durable and not fully resident — at load time. The loader's contract is that whatever it accepts, the interpreter may trust; this combination must never reach the engine.
-
Confirm an image built before this change is refused on version rather than misread. This is what the version bump is for.
-
Print both properties in
--dump-bc(disasm.ml) so the corpus can pin them from the compiler side. -
Update
00-wob-format.md's descriptor section and its version history in this commit. -
Verify:
just woc-build,just wovm-build,just wovm-test,just oop-e2eall green; a stale.wobis rejected clearly. -
Commit. Draft:
feat(wob): v7 — class descriptor carries durability and residency.
Task 4 — engine: a volatile table skips the WAL
Files: modify database/src/db.c (the three WAL-append sites at :26-32,
:48-51, :71-74), database/src/wal.c (replay), database/src/table.h /
table.c if the per-table property needs to reach the choke point;
create fixtures under tests/corpus/run/.
Interfaces:
-
Consumes: Task 3's descriptor fields.
-
Produces: the observable behaviour Task 7's gate asserts — no WAL growth for a volatile table, and an empty table after restart.
-
Reach the per-table durability property at the three append sites in
db.c. Each already guards onvm->rt.walbeing non-null; the condition becomes "a WAL is open and this table is durable". Do not add a second choke point —database/src/CODE-LOGIC.mdnames these as the only places storage may be staged, and that invariant is worth more than the convenience. -
Confirm the ack path is unchanged for durable tables: RAM applied, record staged, one commit before the ack, and a failed commit still removes the row so RAM never claims what disk never acknowledged (the comment at
db.c:29-31states this contract — preserve it exactly). -
Make replay skip records belonging to a class whose descriptor now says volatile. A WAL written when a table was durable and replayed after the source changed is a real case; silently resurrecting rows into a table declared not to have any is worse than refusing.
-
Decide and implement the mismatch policy for that case: refuse at startup naming the class, rather than skipping silently. A silent skip is a data-loss surprise; the spec's Migration section commits to refusing on mismatch.
-
Add a
runfixture: two tables, one durable and one volatile, inserts into both, and a second process run proving the durable rows replayed and the volatile table is empty. -
Verify:
just oop-e2e,just employee,just db-actorgreen; measure that a volatile table's inserts produce no WAL growth (compare file size, do not assert it). -
Commit. Draft:
feat(db): durable:false skips the WAL append and replay.
Task 5 — the resident: keys read path
Retraction, 2026-08-26. This plan originally had a Task 5 that rewrote
db_val_encode/db_val_decodeinto a "self-contained, offset-based" record format, described as the iteration's one real rewrite. That task was fictional and has been deleted.table.c'sdb_val_encodebuilds the in-memory slot; the file record is a separate encoding inwal.c, and it has been flat since iteration 9:enc_valinlines every kind recursively with no pointer anywhere,dec_valreads it back into fresh engine values, a record isWO_WAL_INSERT | class_id | id | <value per field>inside thelen|crc|payload|markframe, andscan_record(fd, off, …)alreadypreads and CRC-verifies a record at an arbitrary offset. Nothing about the row encoding needs to change.The real difficulty is offset capture, and it lives in this task.
wo_wal_append_insertcallsstage()into a buffer (opened at 1 MiB inmain.c:210), so a record's final file offset is unknown at append time and known only when that buffer flushes. Threading an accurate offset back to the caller through a buffered writer — correct across a partial flush, a failed commit, and a torn tail — is where to expect the bugs.
Files: modify database/src/table.c / table.h (the per-table id map, the
slab path), database/src/db.c (insert/read/update/delete), database/src/wal.c
(boot map rebuild); create fixtures under tests/corpus/run/.
Interfaces:
-
Consumes: Task 3's descriptor fields, and
wal.c's existingenc_val/dec_val/scan_record— see the note below. -
Produces: a table whose rows are not resident, serving reads by offset.
-
Offset capture first, before any map exists. Make the staging path in
wal.cable to report the file offset a record will occupy. Decide between computing it as the buffer's base file offset plus the record's position within the buffer, or deferring the report until flush; whichever is chosen, the offset must be wrong in no case — a wrong offset reads a neighbouring record and passes its CRC. -
Prove offset capture in isolation before it has a consumer: a unit test that appends records straddling a buffer boundary, flushes, then reads each back by its reported offset via
scan_recordand asserts the recovered id matches the one appended. Include a failed-commit case, where no offset must be published for a record that never reached disk. -
For a
resident: keystable, replace the slab retention with an id→offset map. Keep every index resident: the id map, each secondary index, and each@uniqueshadow. That residency is what makes the arithmetic work (≈3.8 GB of index for a 120 GB table) and what makes the constraints correct. -
Insert: append the record as Task 4 leaves it, then record id→offset instead of retaining a slab row.
-
Read by id: map lookup,
preadat the offset, verify the CRC the frame already carries, decode viadec_val. Usepreadand notO_DIRECT— the kernel page cache is deliberately the hot copy. -
Update: append a new record, repoint the offset. The superseded record becomes garbage; do not attempt reclamation here — that is databasev2 3.
-
Delete: append a tombstone, drop the id from the map and from every index.
-
Scan: walk the log sequentially rather than issuing one
preadper row, because a sequential walk is the case this layout is best at and a per-row read would make scans pathological. -
Boot: rebuild the id→offset map by replaying the log. Correct, and O(entire history) — record that cost in the iteration and note that databasev2 3's snapshot must persist the map so boot stops rescanning.
-
Confirm the constraints hold across the boundary, with a fixture each:
@uniquerefuses a duplicate whose conflicting row is not resident, and FK-restrict refuses a delete whose only referrer is not resident. These two are the correctness core; a constraint that silently checks only resident rows must never ship. -
Confirm
refnavigation still works, at the cost of a read per hop. -
Add a fixture: a table whose row count exceeds any plausible resident budget, read back by id and scanned in full, byte-identical.
-
Verify:
just oop-e2e,just employee,just db-actorgreen; ASan clean;just db-benchshows theresident: allread baseline unmoved. -
Commit. Draft:
feat(db): resident:keys — rows read from the log by offset.
Task 6 — the two runtime refusals
Files: modify runtime/src/main.c (the WO_DATA block at :199-215),
plus wherever per-table accounting lands from Task 6.
Interfaces:
-
Consumes: Task 3's descriptor fields, Task 5's accounting.
-
Refuse
durable: truewith noWO_DATA. Todaymain.c:199opens a WAL only when the variable is set, anddb.cskips appends when it is not — so a program that declares durability and is given nowhere to put it silently loses everything. Refuse at startup, naming the first durable class found. This is the single most valuable line in the plan and is independent of residency. -
Provide the escape hatch the refusal implies: a program that genuinely wants an ephemeral run must be able to say so, either by declaring its tables volatile or by an explicit opt-out flag. Decide which and document it — a refusal with no stated way forward is a worse bug than the silent loss.
-
Implement the byte budget: estimated resident footprint across all tables, breached loudly with a message naming the largest offending table and the exact annotation to add.
-
Default the budget to a fraction of host-detected available memory, not to "none" — a budget nobody sets cannot produce the diagnostic that is this design's main deliverable, and the 120 GB developer would still meet the OOM killer. Use a conservative placeholder fraction and mark the value explicitly unset-pending in both the code comment and the iteration: the real number comes from databasev2 1's swap-onset measurement.
-
Make the accounting's error bound explicit where it is documented. It estimates RSS; it is not RSS, and pretending otherwise would make the budget untrustworthy the first time someone checked it.
-
Add CLI-smoke coverage for both refusals, including the exit code and the first line of stderr — the shape scripts depend on.
-
Verify:
bash runtime/test/cli_smoke.sh,just wovm-test,just employee,just db-actorgreen; every sample that setsWO_DATAstill runs, and one that does not is now refused or explicitly opted out. -
Commit. Draft:
feat(rt): refuse silent volatility, and bound resident footprint.
Task 7 — measure, gate, document, close out
Files: modify scripts/db-bench.py and docs/examples/db-bench/,
bench/baseline.json, docs/plan/perf-targets.md,
docs/plan/oop-vm/04-db-binding.md, database/src/CODE-LOGIC.md,
docs/stories/databasev2/02-table-storage-modes.md,
docs/stories/databasev2/00-story.md, docs/stories/00-status.md,
docs/guides/language-surface.md.
- Add db-bench legs for the
resident: keysread and write paths at a size that exceeds resident memory, and a volatile-write leg showing the per-table saving from Task 4. - Add the resulting rows to
bench/baseline.jsonwith per-class tolerances following iteration 22's policy, and confirm the gate bites on a doctored metric — a gate that only prints is not a gate. - Publish the read amplification in
perf-targets.md: how much slower a non-resident read is than a resident one, as a number a developer can plan around, alongside the measured per-row index footprint. - Run the crash battery on a
resident: keystable:kill -9mid-append and mid-read, replay, and assert no acked write lost and no row visible twice. - Update
04-db-binding.mdwith the residency contract and the record layout, anddatabase/src/CODE-LOGIC.mdwith the encode/decode rewrite and the boot-rebuild cost. - Rewrite
docs/stories/databasev2/02-table-storage-modes.mdaround what shipped and remove its supersede banner; correct the track index (00-story.md), whose §"The lever" still describes the three-modecolddesign the brainstorm replaced. - Set the iteration's frontmatter
status: doneand add the board standup entry answering the six questions, including which.dev/referenceprojects were used. - Verify: the whole battery —
just woc-test,just wovm-test,just oop-e2e,just oop-accept,just employee,just db-actor,just db-bench,just web-app,just site,just linkcheck. - Commit. Draft:
feat(db): table residency — measured, gated, documented.
Self-review against the spec
Checked section by section; recorded here so a reviewer can see what was and was not covered.
| Spec section | Task |
|---|---|
| Grammar (two arguments, defaults) | 1 |
| The four combinations, incl. the refused one | 1 (parse), 3 (loader) |
| Compiler refusals: bad value, given twice, retired words | 1 |
| Compiler refusal: durable ref into volatile | 2 |
| Format / descriptor / version bump | 3 |
durable: false skips the WAL |
4 |
| Replay skips or refuses on mismatch | 4 |
| Self-contained offset-based records | none — already exists in wal.c since iteration 9. Claim retracted 2026-08-26; see the spec section of the same name. |
| Read / update / delete / scan / boot for non-resident, incl. offset capture | 5 |
@unique and FK-restrict across the boundary |
5 |
Startup refusal: durable with no WO_DATA |
6 |
| Byte budget, default fraction, breach diagnostic | 6 |
| Proof plan: baseline, amplification, crash battery | 7 |
| Docs: catalog, language surface, binding contract, CODE-LOGIC | 1, 3, 7 |
Gaps found and closed during review: the spec's escape hatch for an intentionally ephemeral run was implied but never stated — added as an explicit step in Task 6, because a refusal with no way forward is worse than the silent loss it replaces. The spec's note that databasev2 3's snapshot should persist the offset map is now a recorded step in Task 5 rather than prose only.
Deliberately not in this plan: checkpoint and compaction (databasev2 3), eviction and a resident row cache (databasev2 5), io_uring on the read path (databasev2 4's note), per-shard residency for volatile tables (recorded as a candidate in the spec), and any conversion of an existing dataset between settings — the spec commits to refusing on mismatch, not converting.