- 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>
196 lines
11 KiB
Markdown
196 lines
11 KiB
Markdown
---
|
||
track: databasev2
|
||
iteration: "2"
|
||
status: refine
|
||
---
|
||
|
||
# databasev2 2 — `@table` storage modes: durability becomes a language decision
|
||
|
||
> Part of [Story — databasev2: the database beyond RAM](00-story.md).
|
||
> Needs [1](01-ram-ceiling-measurement.md) — 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_DATA` is
|
||
> set and every `@table` is 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-valued `mode:` enum including `cold`.
|
||
> 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](00-story.md) and `docs/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](06-cold-tiering.md), 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
|
||
`ram` table 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.** `ram` tables 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](05-bounded-tables-eviction.md) builds on.
|
||
- **Keep principle 7 intact and say why.** RAM stays authoritative for every
|
||
table that says so. `cold` is a declared, per-table exception a developer opts
|
||
into with the trade visible at the declaration.
|
||
|
||
## Phases
|
||
|
||
### Phase A — the grammar
|
||
|
||
- Extend the `@table` argument parser with `mode:`. The path is already cut:
|
||
the argument loop matches `name` and `index` and 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 `@table` keeps its meaning.
|
||
- Reject the incoherent cases at parse time: `mode` given twice, an unknown mode
|
||
name. Both belong in the same diagnostic family as the existing `@table`
|
||
errors, 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-test` green; every existing
|
||
`@table` in every sample parses unchanged.
|
||
|
||
### Phase B — the mode reaches the image and the engine
|
||
|
||
- Carry the mode through the class descriptor into the `.wob` image so the
|
||
runtime knows it without re-deriving anything. This is a format change, so it
|
||
moves `WOB_VERSION` and 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 in `wo_row_insert` / `wo_row_remove`, which
|
||
`database/src/CODE-LOGIC.md` names as the only place storage may be mutated.
|
||
A `ram` table stages nothing.
|
||
- Replay must skip records for tables that are now `ram` — a WAL written when a
|
||
table was `durable` and replayed after the source changed is a real
|
||
migration case, and silently resurrecting rows into a `ram` table would be
|
||
worse than refusing.
|
||
- Verify: a `ram` table's inserts produce no WAL growth (measured, not assumed);
|
||
a `durable` table 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_DATA` set with every table `ram` is a program that asked for a data
|
||
directory it will never write to — worth a warning at least.
|
||
- The interesting case is a `ram` table participating in a `ref`/`backlink`
|
||
relation with a `durable` one. 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-fail` fixtures for each refusal; each carries the
|
||
exact `WO-E###` the catalog now documents.
|
||
|
||
### Phase D — prove it on a real workload
|
||
|
||
- Give `docs/examples/employee` or the `db-bench` sample a mixed schema — at
|
||
least one `ram` table and one `durable` — 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-bench` so the per-table write-path
|
||
saving appears as a baseline number, not a claim.
|
||
- Verify: `just employee`, `just db-actor`, `just db-bench`, `oop-accept` green.
|
||
|
||
### Phase E — document the contract
|
||
|
||
- The `@table` mode surface in the language-surface guide and the db-binding
|
||
contract; the new diagnostics in the error catalog; the mode's effect on
|
||
replay in `database/src/CODE-LOGIC.md`.
|
||
- Verify: `just linkcheck` clean; the language-surface guide's `@table` row
|
||
matches what the parser actually accepts.
|
||
|
||
## Acceptance Criteria
|
||
|
||
- **Given** every existing `@table` declaration in the repository, **when** it is
|
||
compiled after this change, **then** behaviour is byte-identical — `durable`
|
||
is the default and nothing opts in silently.
|
||
- **Given** a table declared `mode: ram`, **when** rows are inserted with
|
||
`WO_DATA` set, **then** the WAL does not grow, and after a restart the table is
|
||
empty while `durable` tables in the same program replay intact.
|
||
- **Given** `mode: ram` and a measured insert workload, **when** it runs against
|
||
the same shape as a `durable` table, **then** the write-path saving is visible
|
||
in `bench/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 `durable` table holding a `ref` into a `ram` table, **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 `.wob` format 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](06-cold-tiering.md). Until then
|
||
a `cold` declaration must be refused rather than silently treated as
|
||
`durable` — accepting a mode that does nothing is how a feature becomes a lie.
|
||
- **Per-table capacity limits and eviction** — [5](05-bounded-tables-eviction.md).
|
||
This iteration says what a table *is*; that one says how much of it there may
|
||
be.
|
||
- **`@table` feature flags and `transaction { }`** — language
|
||
[iteration 18](../language-runtime-database/18-memory-db-features.md), 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](07-single-file-db.md) 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:
|
||
|
||
1. **What are the modes called?** `ram` / `durable` / `cold` is descriptive of
|
||
mechanism. `scratch` / `persistent` / `archived` is 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.
|
||
2. **How hard does the compiler push?** Three levels: warn on the suspicious
|
||
cases; refuse the provably-broken ones (a `durable`→`ram` `ref`); or a full
|
||
dataflow check that an insert into a `ram` table is never expected to persist.
|
||
The third is not statically decidable in general. Leaning: refuse the
|
||
relation case (provable, high value), warn on the `WO_DATA`-with-no-durable-
|
||
table case, and stop there.
|
||
3. **What is the default, and does it depend on iteration 1?** `durable` keeps
|
||
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
|
||
`@table` states 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.
|
||
4. **Does `mode: ram` imply 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. A `ram` table has no WAL — so does it still need to live on
|
||
the owner? A per-shard `ram` table 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.
|