- `resident: all | keys` replaces `resident: all | index`. Two reasons beyond taste: it kills the collision with the `index:` argument (`@table(index: [customer], resident: index)` read badly), and it puts both values on ONE axis — each now answers "what row data stays resident", where `all`/`index` mixed a quantity with a structure name - accurate as well as clearer: what stays resident is the id->offset map, the secondary indexes and the unique shadows — all key structures; row payloads are exactly what leaves. `resident: none` was rejected as overclaiming, since the indexes very much are resident - checked for collisions: neither `all` nor `keys` is a keyword or a builtin (`key_at`/`val_at` exist, bare `keys` does not) - the spec's wart note became a recorded decision; the rejected spelling is kept quoted so the rationale still reads - fixes a bug I introduced in the 2026-08-26 track move: all six moved iterations carried a banner reading "Part of [Story — the database beyond RAM]" whose link pointed at the LANGUAGE arc — correct target, lying text, the exact failure mode the link audit warned about. Banners now point at the databasev2 story, and the original "Part of" line says plainly which track the iteration was authored in before the move - 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: keys) 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.