- driving case: a 120 GB order table on a 32 GB host. Not a tuning problem; no eviction policy fixes it. Developer accepted reconsidering the principle - principle 7 rewritten: durability half UNCHANGED and unconditional (WAL-logged, fsync before ack, CRC-dropped torn tail); residency half demoted from law to per-table declaration. Old wording quoted in place so the amendment is legible, with the reason: a doctrine a real workload cannot satisfy gets ignored, and the failure it produced was an OOM kill - spec: docs/superpowers/specs/2026-08-26-table-residency-design.md One log-structured engine — the WAL already holds every row, so keep an in-RAM id->offset map and pread rows back. No second engine, no user-space row cache (the kernel page cache is the hot copy, which is already this repo's stated position and why it avoids O_DIRECT) - arithmetic that makes it work: 240M rows x 16 B of index = ~3.8 GB resident in 32 GB. Indexes stay resident, rows do not. Buys ~2 orders of magnitude, not infinity — stated plainly in the spec - grammar: two optional keys, `durable: true|false` and `resident: all|index`, both defaulting to today's behaviour, so all 28 existing @table declarations compile untouched and no golden is reblessed - rejected, with reasons recorded: mmap (rows are pointer-bearing — table.c returns (uintptr_t)t as the slot word), buffer pool (the Rust-era phase-12 design that died with that track), paged B-tree (stays rejected), a three-valued enum, automatic spill, disk-backed-by-default - self-review caught the budget defaulting to "none" while promising the ERP developer a diagnostic instead of the OOM killer — contradiction fixed: the budget defaults to a fraction of host memory, and its value comes from databasev2 1's swap-onset measurement - live docs that contradicted the amendment updated (subagent doctrine, its guide, discarded.md's two rows, iteration 04's read claim, 07, 38); dated specs/plans left as records. linkcheck 0 broken / 0 anchors Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 KiB
| track | iteration | status |
|---|---|---|
| databasev2 | 2 | refine |
databasev2 2 — @table storage modes: durability becomes a language decision
Part of Story — databasev2: the database beyond RAM. Needs 1 — a mode's default should follow from a measurement, not a preference.
The developer's ask, and the language enrichment this track exists for. Today durability is one environment variable for a whole process:
WO_DATAis set and every@tableis WAL-logged, or it is not and none are (runtime/src/main.c). Real applications are not uniform. A session table, a rate-limit counter and a page cache are resident and disposable; an orders table is resident and precious; an audit log is precious and rarely read. One global switch forces "everything is precious" or "nothing is", and the developer pays for the wrong one either way.
⚠ Superseded in part, 2026-08-26. The brainstorm settled on a different shape than this document describes: one log-structured engine where the WAL is the row store, with residency declared per table (
resident: all/resident: index) rather than a three-valuedmode:enum includingcold. Principle 7 was amended accordingly — the log is authoritative, residency is the declaration. This file is rewritten once the grammar is approved; read the track index anddocs/00-principles.md§7 as current.
Goals
- Move the storage decision to the declaration site.
@table(mode: ...), chosen per table, in the source, where the person who knows what the data is worth is already writing. An operations runbook is the wrong place for a fact the compiler could hold. - Three modes, each earning its existence.
ram— resident, never logged, gone on restart.durable— today's behaviour, resident and ack-after-fsync, and the default so every existing program is byte-identical.cold— durable and not required to be resident, declared here and implemented in 6, because a mode with no engine behind it is a promise. - Make the compiler enforce what the mode means. This is the part that makes
it a language feature rather than a config key. A program that inserts into a
ramtable and expects the row after a restart is stating a contradiction, and the compiler is the right place to say so — at minimum for the cases it can see statically, with the diagnostic catalogued like every other. - Let the engine act on it.
ramtables skip the WAL write entirely, which is not merely a saving — it is the 66× gap iteration 22 measured (4.5k durable vs 297k RAM inserts/s) becoming available per table instead of per process. They also become the first candidates to shed under pressure, which is what 5 builds on. - Keep principle 7 intact and say why. RAM stays authoritative for every
table that says so.
coldis a declared, per-table exception a developer opts into with the trade visible at the declaration.
Phases
Phase A — the grammar
- Extend the
@tableargument parser withmode:. The path is already cut: the argument loop matchesnameandindexand rejects anything else with a catalogued diagnostic (unknown @table argument ... (supported: name, index)), so this is one more arm plus an updated message. - Extend
Ast.table_cfg— today{ table_name; indexes }— with the mode, and give it the default so every existing@tablekeeps its meaning. - Reject the incoherent cases at parse time:
modegiven twice, an unknown mode name. Both belong in the same diagnostic family as the existing@tableerrors, and both go in the error catalog in this change, not later — that catalog went stale once by exactly that omission. - Verify: golden AST fixtures for each mode;
woc-testgreen; every existing@tablein every sample parses unchanged.
Phase B — the mode reaches the image and the engine
- Carry the mode through the class descriptor into the
.wobimage so the runtime knows it without re-deriving anything. This is a format change, so it movesWOB_VERSIONand the format contract in the same commit as the code. - The engine consults it at the one choke point that already exists: the
INDEX HOOK/ WAL staging site inwo_row_insert/wo_row_remove, whichdatabase/src/CODE-LOGIC.mdnames as the only place storage may be mutated. Aramtable stages nothing. - Replay must skip records for tables that are now
ram— a WAL written when a table wasdurableand replayed after the source changed is a real migration case, and silently resurrecting rows into aramtable would be worse than refusing. - Verify: a
ramtable's inserts produce no WAL growth (measured, not assumed); adurabletable is byte-identical to today; a mode change across a restart is handled explicitly rather than by accident.
Phase C — the compiler's enforcement
- Decide how far static checking goes (fork 2) and implement that much. The
floor:
WO_DATAset with every tableramis a program that asked for a data directory it will never write to — worth a warning at least. - The interesting case is a
ramtable participating in aref/backlinkrelation with adurableone. A durable row holding a foreign key into a table that evaporates on restart is a dangling reference by construction, and FK-restrict cannot save it. This is the check most worth having, and it is statically visible from the class table. - Verify: corpus
compile-failfixtures for each refusal; each carries the exactWO-E###the catalog now documents.
Phase D — prove it on a real workload
- Give
docs/examples/employeeor thedb-benchsample a mixed schema — at least oneramtable and onedurable— and gate the distinction: after a restart, the durable rows are present and the ram rows are gone. That single assertion is the whole feature. - Extend the durability legs of
just db-benchso the per-table write-path saving appears as a baseline number, not a claim. - Verify:
just employee,just db-actor,just db-bench,oop-acceptgreen.
Phase E — document the contract
- The
@tablemode surface in the language-surface guide and the db-binding contract; the new diagnostics in the error catalog; the mode's effect on replay indatabase/src/CODE-LOGIC.md. - Verify:
just linkcheckclean; the language-surface guide's@tablerow matches what the parser actually accepts.
Acceptance Criteria
- Given every existing
@tabledeclaration in the repository, when it is compiled after this change, then behaviour is byte-identical —durableis the default and nothing opts in silently. - Given a table declared
mode: ram, when rows are inserted withWO_DATAset, then the WAL does not grow, and after a restart the table is empty whiledurabletables in the same program replay intact. - Given
mode: ramand a measured insert workload, when it runs against the same shape as adurabletable, then the write-path saving is visible inbench/baseline.json— the per-table half of iteration 22's 66× gap. - Given an unknown mode name or
mode:given twice, when it is compiled, then it fails with the catalogued diagnostic naming the legal modes. - Given a
durabletable holding arefinto aramtable, when it is compiled, then the compiler refuses (or warns, per fork 2) — a persistent row cannot reference one that evaporates. - Given a WAL containing records for a table whose source now says
ram, when the program starts, then the situation is handled explicitly (refuse, or skip and report) and never by silently loading rows into a table declared not to have any. - Given the
.wobformat change, when an image from the previous version is loaded, then the loader refuses it clearly on the version rather than misreading a descriptor.
Out Of Scope
- Implementing
cold. Declared here so the mode set is settled and the format carries it; the engine behaviour is 6. Until then acolddeclaration must be refused rather than silently treated asdurable— accepting a mode that does nothing is how a feature becomes a lie. - Per-table capacity limits and eviction — 5. This iteration says what a table is; that one says how much of it there may be.
@tablefeature flags andtransaction { }— language iteration 18, whose spec is approved and deliberately left whole.- Per-table WAL files. One log, one writer, shard 0 — the invariant stage 3 established and 7 depends on. Modes decide whether a table logs, never where.
- Migrating an existing dataset between modes. A schema-change story, and the repo already records destructive migrations as a recorded future.
- Encryption at rest, compression of the WAL. Neither has a consumer.
Info
The grammar surface this touches, read from the source: the parser's @table
argument loop and its unknown @table argument failure; Ast.table_cfg as
{ table_name : string option; indexes : string list list }; and the class
descriptor in runtime/src/wob.h that the loader validates. Adding a key is
genuinely small — the semantics are the iteration.
Forks the spec must settle:
- What are the modes called?
ram/durable/coldis descriptive of mechanism.scratch/persistent/archivedis descriptive of intent and is what a developer reasons about. The names are the API and are hard to change later; leaning the intent-shaped set for the first two if a short-enough pair can be found, since a developer choosing a mode is thinking about what the data is for, not about where it sits. - How hard does the compiler push? Three levels: warn on the suspicious
cases; refuse the provably-broken ones (a
durable→ramref); or a full dataflow check that an insert into aramtable is never expected to persist. The third is not statically decidable in general. Leaning: refuse the relation case (provable, high value), warn on theWO_DATA-with-no-durable- table case, and stop there. - What is the default, and does it depend on iteration 1?
durablekeeps every existing program identical, which is nearly decisive. But if iteration 1's numbers show the WAL write dominating a workload nobody wanted durable, there is an argument for making the choice mandatory — no default, every@tablestates its mode. That is a bigger source change and a better language; the fork is whether the churn is worth it now or at 1.0. - Does
mode: ramimply anything about the actor/DB-actor path? Stage 3 marshals worker-shard statements to shard 0 because the owner shard holds the store and the WAL. Aramtable has no WAL — so does it still need to live on the owner? A per-shardramtable would be dramatically faster and a different consistency story. Tempting, out of scope here, and worth recording as a candidate rather than deciding in passing.