writeonce/docs/examples/residency/README.md
shoney.arickathil 58dcb1f969 test(db2-7): gate leg for WO_DATA=<file> — residency section 8, db-bench --wo-data-file
- residency-accept.sh section 8 (11 checks): file-form seed -> restart prints
  the directory form's line; `find -mindepth 1` shows exactly app.db; missing
  parent exits 2 naming path + parent, no mkdir -p; a fifo exits 2 "neither a
  regular file nor a directory"; `d/` still writes d/shard-0.wal; `nodir/`
  keeps the pre-7 "cannot open .../nodir//shard-0.wal" bytes; WO_EPHEMERAL=1
  with the file exits 2 on the 6a conflict
- kill -9 battery against app.db: stdbuf -oL vehicle, asserts the kill landed
  (rc 137) before verifying every acked row replays; forced compaction
  (WO_CHECKPOINT_BYTES=1, WO_WAL_STATS proves >= 1 ran) leaves app.db the only
  artifact and every row replays
- failing-first on the pre-7 wovm: 9 of 12 new checks red ("cannot open
  .../app.db/shard-0.wal"); the two trailing-slash pins and the 6a conflict
  pass by construction — they pin what must stay byte-identical
- db-bench.py --wo-data-file: restart proof + crash battery against
  <tmp>/app.db, legs tagged .file, file form also asserts app.db is the only
  artifact; no metric, bench/baseline.json untouched; quick run unchanged
  without the flag (181 checks / 5 failures both ways, all five the known
  residency.keys.fit rc 74)
- READMEs: db-bench env-knob row for WO_DATA=<path>.db + the driver flag;
  residency run instructions name the file form
- gates: just residency 32/1 (the seed rc, pre-existing), make -C runtime
  test 21 suites 8452/0, just oop-e2e 129/0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit aaea6b2c0f818efd0cdf472ccbd9bdd09eac5554)
2026-09-15 01:16:13 +02:00

5.8 KiB

residency — per-table storage

What databasev2 2 added: durable and resident, declared per @table instead of one environment variable for the whole process.

Before it, WO_DATA was the only switch. Set, and every table is WAL-logged; unset, and none are. Applications are not uniform — a session table is disposable, a product's stock level is not, and a catalogue large enough to matter does not fit in RAM at all. One global switch forces "everything is precious" or "nothing is", and you pay for whichever is wrong.

Run it

just residency

The gate writes the example's own output to /tmp/residency.log, banner- separated, so you can tail -F it while it runs.

Or by hand, which is the whole demonstration — the same program twice against one data directory:

compiler/_build/default/bin/woc --emit docs/examples/residency/main.wo -o /tmp/residency.wob
mkdir -p /tmp/residency-data          # WO_DATA=<dir> must exist; wovm will not create it (or WO_DATA=<path>.db: one file, parent must exist)
WO_DATA=/tmp/residency-data runtime/wovm /tmp/residency.wob seed
WO_DATA=/tmp/residency-data runtime/wovm /tmp/residency.wob order
seeded: products=2 carts=1 SKU-1 stock=10
after restart: products=2 carts=0
order: SKU-1 stock 10 -> 7
ok: first order placed; run `order` again to see it replay

The second run inserts nothing. Both products come back from the log; the cart does not, because it was never written to it. Both tables were filled by the same code — only the annotation differs, so the difference after the restart is the annotation's doing and nothing else's.

Run order a third time. Stock goes 7 → 4, and the example says so: a level below the seeded 10 can only mean an earlier order's update survived a restart. That is the stronger claim — not just that inserts replay, but that a field change does.

The three modes

Declaration Meaning State today
durable: true (default) WAL-logged, replayed at boot ✅ works
durable: false never written to the log; costs no disk and no fsync; empty after a restart ✅ works
resident: all (default) every row's payload lives in RAM ✅ works
resident: keys the id map stays resident, the payload lives in the WAL and is read back by offset ✅ works, including update

resident: keys, and what it costs

Product above is declared resident: keys — the mode the track exists for. A catalogue is the table that outgrows RAM first: only the sku -> row id map stays in memory, and each row's payload is read back from the log. Storage, the read paths, scans (including through the sku index, a Text column), @unique, deletes, checkpoint survival, and update all work.

A read costs one pread plus every delta since the row's last checkpoint. Updating a keys-resident row has no slab slot to mutate, so it is read-modify-append: place_order moving stock appends a small delta record (id, field, new value) chained off the row's previous record, rather than rewriting sku, name and price to change one integer — the argument for a delta at all, on the hottest write path a shop has. Reading the row back folds that chain: the base row plus every delta not yet superseded or checkpointed away. A row updated once costs a pread and one small decode on top of the base read; a row updated many times between checkpoints costs one decode per delta still in the chain.

Three limitations ship with this, on purpose documented rather than fixed:

  1. Mid-drain stale reads. A request reading a row inside the same uncommitted drain, while an earlier request in that drain has an in-flight update to it, may see the last durable value, not that request's write. Read-your-writes holds within a request, not across requests sharing a drain. Closing it needs the fold to consult the WAL's staging buffer generally, which is materially bigger than this feature.
  2. Replay is O(N²) in a row's delta-chain length. Each replayed delta re-folds the whole chain back to its base record, so boot cost for one long chain is quadratic in that chain's length.
  3. Compaction cannot see chain length. The checkpoint that flattens delta chains triggers on the log's overall byte ratio, not on any one row's delta count — so a single hot row taking many small updates (a popular SKU, exactly this example's workload) can grow a long personal chain without moving the aggregate ratio enough to fire a checkpoint. This mode's design deliberately does not cap chain length, trusting compaction to bound it instead; for a hot-row workload, it may not.

The refusal that used to stand here was earned, not reflexive: an audit before lifting it found that delete on a keys-resident table was reading a WAL byte offset as a slab index and freeing whatever it landed on — memory corruption, not a missing feature — fixed and pinned by a test that SEGVs against the old code. The same audit, repeated before lifting the update refusal, found a second bug of the same shape: three index functions (and db.c's field-read and probe paths) were reading a keys-resident row's Text column through the wrong struct layout, reproduced as a genuine ASan heap-buffer-overflow. Fixed at the root — a keys-resident row now holds the same engine-encoded values a resident: all row always has — and pinned by a test that reproduces the overflow against the pre-fix code.

What this example does NOT show

The mode-mismatch startup refusal, the zero-WAL-bytes measurement, and the two compile-time refusals are proven by scripts/residency-accept.sh against purpose-built snippets, because each needs a deliberately broken program or a byte-level assertion on the log file. This example is the readable half.