- loader.c: delete the INCOMPLETE-update BAIL; durable:false + resident:keys stays refused (nowhere to read from) - table.c: root-cause fix for the Text-index gap — a keys-resident borrow now holds ENGINE values, matching wo_row_ptr's contract (table.h's "no VM pointer" doctrine), not a VM-decoded row. Fixes idx_hash/idx_cols_equal/wo_idx_probe AND db.c's GET_FIELD/PROBE arms with one change; reproduced pre-fix as an ASan heap-buffer-overflow - docs/examples/residency: Product is genuinely resident:keys; residency-accept.sh's refusal leg replaced by proving the program runs and stock survives a restart (11/0) - test_wal.c: oracle test drives resident:all and resident:keys through the same update sequence and asserts identical rows; Text-indexed-update test catches the representation bug; five pre-existing tests corrected to the fixed contract (4746/0) - story, README, status board, CODE-LOGIC.md updated; three known limitations documented: mid-drain stale reads, O(N^2) replay in chain length, compaction blind to per-row chain length Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit b87c68f950f01aa5e572fbb86a0f374adc83d813)
113 lines
5.7 KiB
Markdown
113 lines
5.7 KiB
Markdown
# residency — per-table storage
|
|
|
|
What [databasev2 2](../../stories/databasev2/02-table-storage-modes.md) 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 must exist; wovm will not create it
|
|
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.
|