feat(db): durable:false skips the WAL append and replay

Task 4 of docs/superpowers/plans/2026-08-26-table-residency.md — the first
behavioural change in the iteration.

- db.c: one predicate, `table_is_durable`, gating the three EXISTING mutation
  sites. Kept as a function rather than an inlined condition so
  database/src/CODE-LOGIC.md's "nothing else may mutate storage" claim keeps
  holding — the choke points stayed three
- the ack contract is untouched for durable tables: RAM applied, record
  staged, one commit before the ack, and a failed commit still removes the row
- replay: a log holding records for a class the image now declares volatile is
  a real migration case, not corruption. apply_record returns -2 (distinct
  from -1), wo_wal_replay_ex reports the class id, and main.c names it and
  exits 2. `wo_wal_replay` stays as the NULL wrapper, so all 156 WAL unit
  checks are untouched
- measured, not asserted: 50 inserts wrote 1500 WAL bytes into a durable
  table and ZERO into a volatile one. The file's SIZE proves nothing (it is
  fallocate'd to 1 MiB up front), so the gate measures the non-zero prefix

BUG I INTRODUCED AND CAUGHT: the mismatch message first printed the class name
with %s, but wo_str.data is `char data[]` with NO NUL terminator (obj.h) — a
buffer over-read. Now %.*s with the explicit length, and re-verified under
ASan.

New gate `just residency` (8 checks), because everything above was otherwise
a one-off manual measurement: restart behaviour, the zero-byte write path, the
mismatch refusal (exit 2, names the class, NOT reported as corruption), and
both compile-time refusals. Its own first run failed two checks for a bug in
the script rather than the feature — `woc | grep` under `set -o pipefail`
returns woc's exit 1 even when grep matches, since woc exits 1 whenever it
reports diagnostics. Captures first now, with the reason noted inline.

Also new: corpus run/table-volatile-inprocess pins that a volatile table is a
FULL table in-process — same @unique enforcement, same index probe, same query
surface. Only survival differs, and that is unobservable from inside one
process.

Gates: woc-test 557/0, 18 runtime suites 0 fail, cli_smoke OK, oop-e2e 119/0
(was 118), residency 8/0, employee 8/0, db-actor 8/0, site 21/0, ASan clean on
the new replay path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-27 08:23:49 +02:00
parent 477f8d1b2b
commit b74e13d21e
9 changed files with 266 additions and 6 deletions

View file

@ -23,6 +23,21 @@ VM values ──copy──▶ row slots (engine-owned malloc) ──copy──
the id hash maps id → slot. Ids are never reused (per-table counter, the id hash maps id → slot. Ids are never reused (per-table counter,
shard-interleaved `S+1, S+1+N, …`), which is also what makes the hash's shard-interleaved `S+1, S+1+N, …`), which is also what makes the hash's
tombstone sentinel safe. tombstone sentinel safe.
- **Storage is per-table since databasev2 2.** `@table(durable: false)` sets
`WO_CLASSF_VOLATILE` in the class descriptor (`.wob` v7), and `db.c`'s
`table_is_durable` gates all three mutation sites: a volatile table stages
nothing, so it pays none of the fsync cost and is empty after a restart.
Measured: 50 inserts wrote 1500 WAL bytes durable, **0** volatile. The three
sites stayed three — the predicate is one function, not an inlined condition,
precisely so this file's "nothing else may mutate storage" claim keeps
holding.
- **A mode mismatch refuses, it does not convert.** If the log holds records
for a class the loaded image now declares volatile, `apply_record` returns
**-2** (distinct from -1 corruption) and `wo_wal_replay_ex` reports the class
id so `main.c` can name it. Silently skipping those records would resurrect
nothing but would also hide a real migration; silently applying them would
load rows into a table declared not to have any. `wo_wal_replay` remains as
the NULL-out-param wrapper so the 156 WAL unit checks are untouched.
- **Choke points**: `wo_row_insert` / `wo_row_remove` carry the `INDEX HOOK` - **Choke points**: `wo_row_insert` / `wo_row_remove` carry the `INDEX HOOK`
comments where Task 4's secondary indexes attach and Task 2's WAL stages comments where Task 4's secondary indexes attach and Task 2's WAL stages
its record. Nothing else may mutate storage. its record. Nothing else may mutate storage.

View file

@ -7,6 +7,17 @@
#include "table.h" #include "table.h"
#include "wal.h" #include "wal.h"
/* databasev2 2: is this table's storage durable? A `@table(durable: false)`
* class carries WO_CLASSF_VOLATILE and is never staged to the WAL — no
* record, no fsync, ack straight from RAM. One predicate for all three
* mutation sites below: `database/src/CODE-LOGIC.md` names those as the only
* places storage may be staged, and that invariant is worth more than the
* convenience of inlining this. cid is always loader-validated by the time a
* mutation has succeeded, so no bounds check is added here. */
static int table_is_durable(const wo_db *db, uint32_t cid) {
return (db->classes[cid].flags & WO_CLASSF_VOLATILE) == 0u;
}
int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
uint32_t A = wo_ins_a(ins), B = wo_ins_b(ins), C = wo_ins_c(ins); uint32_t A = wo_ins_a(ins), B = wo_ins_b(ins), C = wo_ins_c(ins);
wo_db *db = (wo_db *)vm->rt.db; wo_db *db = (wo_db *)vm->rt.db;
@ -24,7 +35,7 @@ int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
: ek == DB_ERR_OOM ? WO_T_OOM : ek == DB_ERR_OOM ? WO_T_OOM
: WO_T_DB; : WO_T_DB;
wo_wal *w = (wo_wal *)vm->rt.wal; wo_wal *w = (wo_wal *)vm->rt.wal;
if (w) { if (w && table_is_durable(db, cid)) {
/* RAM applied, record staged, ONE commit before the ack (the /* RAM applied, record staged, ONE commit before the ack (the
* builtin's return). A failed commit is a failed write: the * builtin's return). A failed commit is a failed write: the
* row is removed again so RAM never claims what disk never * row is removed again so RAM never claims what disk never
@ -46,7 +57,7 @@ int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
if (wo_row_update_field(db, cid, id, field, R[B + 3], msg, &ek) != 0) if (wo_row_update_field(db, cid, id, field, R[B + 3], msg, &ek) != 0)
return ek == DB_ERR_UNIQUE ? WO_T_UNIQUE : ek == DB_ERR_OOM ? WO_T_OOM : WO_T_DB; return ek == DB_ERR_UNIQUE ? WO_T_UNIQUE : ek == DB_ERR_OOM ? WO_T_OOM : WO_T_DB;
wo_wal *w = (wo_wal *)vm->rt.wal; wo_wal *w = (wo_wal *)vm->rt.wal;
if (w) { if (w && table_is_durable(db, cid)) {
if (wo_wal_append_update(w, db, cid, id) != 0 || wo_wal_commit(w) != 0) { if (wo_wal_append_update(w, db, cid, id) != 0 || wo_wal_commit(w) != 0) {
*msg = "wal commit failed"; /* RAM ahead of disk: trap, do not ack */ *msg = "wal commit failed"; /* RAM ahead of disk: trap, do not ack */
return WO_T_IO; return WO_T_IO;
@ -69,7 +80,7 @@ int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
return WO_T_DB; return WO_T_DB;
} }
wo_wal *w = (wo_wal *)vm->rt.wal; wo_wal *w = (wo_wal *)vm->rt.wal;
if (w) { if (w && table_is_durable(db, cid)) {
if (wo_wal_append_remove(w, cid, id) != 0 || wo_wal_commit(w) != 0) { if (wo_wal_append_remove(w, cid, id) != 0 || wo_wal_commit(w) != 0) {
*msg = "wal commit failed"; *msg = "wal commit failed";
return WO_T_IO; return WO_T_IO;

View file

@ -412,6 +412,12 @@ static int apply_record(wo_db *db, const uint8_t *payload, uint32_t len) {
uint32_t cid = rd_u32(&r); uint32_t cid = rd_u32(&r);
uint64_t id = rd_u64(&r); uint64_t id = rd_u64(&r);
if (r.bad || cid >= db->class_cnt) return -1; if (r.bad || cid >= db->class_cnt) return -1;
/* databasev2 2: this log holds records for a class the CURRENT source
* declares `durable: false`. Not corruption — a real migration case (the
* table used to be durable). Refuse rather than convert, and refuse
* rather than silently resurrect rows into a table declared not to have
* any. -2 so the caller can say which of the two it is. */
if (db->classes[cid].flags & WO_CLASSF_VOLATILE) return -2;
if (kind == WO_WAL_REMOVE) return wo_row_remove(db, cid, id); if (kind == WO_WAL_REMOVE) return wo_row_remove(db, cid, id);
if (kind != WO_WAL_INSERT && kind != WO_WAL_UPDATE) return -1; if (kind != WO_WAL_INSERT && kind != WO_WAL_UPDATE) return -1;
if (kind == WO_WAL_UPDATE) { if (kind == WO_WAL_UPDATE) {
@ -444,7 +450,7 @@ static int apply_record(wo_db *db, const uint8_t *payload, uint32_t len) {
return 0; return 0;
} }
int64_t wo_wal_replay(const char *path, wo_db *db) { int64_t wo_wal_replay_ex(const char *path, wo_db *db, uint32_t *volatile_cid) {
int fd = open(path, O_RDONLY); int fd = open(path, O_RDONLY);
if (fd < 0) return errno == ENOENT ? 0 : -1; /* no WAL yet = fresh boot */ if (fd < 0) return errno == ENOENT ? 0 : -1; /* no WAL yet = fresh boot */
uint64_t off = 0; uint64_t off = 0;
@ -453,11 +459,17 @@ int64_t wo_wal_replay(const char *path, wo_db *db) {
uint32_t len; uint32_t len;
uint8_t *payload; uint8_t *payload;
if (scan_record(fd, off, &len, &payload) != 0) break; /* intact prefix ends */ if (scan_record(fd, off, &len, &payload) != 0) break; /* intact prefix ends */
/* peek the class id before applying, so a -2 can name it */
uint32_t rec_cid = len >= 5u ? (uint32_t)payload[1] | ((uint32_t)payload[2] << 8)
| ((uint32_t)payload[3] << 16)
| ((uint32_t)payload[4] << 24)
: 0u;
int rc = apply_record(db, payload, len); int rc = apply_record(db, payload, len);
free(payload); free(payload);
if (rc != 0) { if (rc != 0) {
close(fd); close(fd);
return -1; if (rc == -2 && volatile_cid) *volatile_cid = rec_cid;
return rc == -2 ? -2 : -1;
} }
off += 8u + len + 4u; off += 8u + len + 4u;
applied++; applied++;
@ -466,6 +478,10 @@ int64_t wo_wal_replay(const char *path, wo_db *db) {
return applied; return applied;
} }
int64_t wo_wal_replay(const char *path, wo_db *db) {
return wo_wal_replay_ex(path, db, NULL);
}
int64_t wo_wal_check(const char *path, uint64_t *intact_bytes) { int64_t wo_wal_check(const char *path, uint64_t *intact_bytes) {
int fd = open(path, O_RDONLY); int fd = open(path, O_RDONLY);
if (fd < 0) return -1; if (fd < 0) return -1;

View file

@ -83,6 +83,14 @@ int wo_wal_commit(wo_wal *w);
* the intact prefix and reports it. */ * the intact prefix and reports it. */
int64_t wo_wal_replay(const char *path, wo_db *db); int64_t wo_wal_replay(const char *path, wo_db *db);
/* databasev2 2: as wo_wal_replay, but distinguishes the two failure kinds.
* Returns the applied count on success; -1 on corruption beyond a torn tail;
* -2 when the log holds records for a class the loaded image declares
* `durable: false`, writing that class id through [volatile_cid] if non-NULL.
* The plain wo_wal_replay above is this with NULL, kept so the existing
* callers and the 156 WAL unit checks are untouched. */
int64_t wo_wal_replay_ex(const char *path, wo_db *db, uint32_t *volatile_cid);
/* Offline verification (no engine): scan [path], count intact records. /* Offline verification (no engine): scan [path], count intact records.
* *intact_bytes (optional) = where the intact prefix ends. -1 = open * *intact_bytes (optional) = where the intact prefix ends. -1 = open
* failure. */ * failure. */

View file

@ -66,6 +66,15 @@ fibers:
db-actor: db-actor:
./scripts/db-actor-accept.sh ./scripts/db-actor-accept.sh
# residency: databasev2 2's gate — per-table storage. The corpus covers the
# in-process half; this covers what one process cannot see: a volatile table
# empty after restart while its durable sibling replays, volatile inserts
# writing ZERO WAL bytes (measured against the fallocate'd file's non-zero
# prefix, since its size proves nothing), the mode-mismatch startup refusal,
# and the two compile-time refusals.
residency:
./scripts/residency-accept.sh
# db-bench: iteration 22's campaign (docs/examples/db-bench) — OFF the # db-bench: iteration 22's campaign (docs/examples/db-bench) — OFF the
# fast path, minutes long: ram+durable x 1/N shards, durability legs, # fast path, minutes long: ram+durable x 1/N shards, durability legs,
# gates vs bench/baseline.json. quick = seconds, floors only. # gates vs bench/baseline.json. quick = seconds, floors only.

View file

@ -200,7 +200,36 @@ int main(int argc, char **argv) {
if (data_dir && data_dir[0]) { if (data_dir && data_dir[0]) {
char wal_path[512]; char wal_path[512];
snprintf(wal_path, sizeof wal_path, "%s/shard-0.wal", data_dir); snprintf(wal_path, sizeof wal_path, "%s/shard-0.wal", data_dir);
if (wo_wal_replay(wal_path, &DB) < 0) { uint32_t vol_cid = 0;
int64_t replayed = wo_wal_replay_ex(wal_path, &DB, &vol_cid);
if (replayed == -2) {
/* databasev2 2: not corruption — this log was written when the
* table was durable and the source now says `durable: false`.
* Refusing beats converting, and beats resurrecting rows into a
* table declared not to have any. Name the class so the fix is
* obvious. */
/* wo_str.data is NOT NUL-terminated (obj.h), so the name must be
* printed with an explicit length — %s here would over-read. */
const char *cname = "?";
int cnlen = 1;
if (vol_cid < mod.class_cnt) {
uint32_t k = mod.classes[vol_cid].name;
if (k < mod.const_cnt && mod.consts[k].s) {
cname = mod.consts[k].s->data;
cnlen = (int)mod.consts[k].s->len;
}
}
fprintf(stderr,
"wovm: %s: holds records for `%.*s`, which this program declares "
"`@table(durable: false)` — refusing to start. Either restore "
"`durable: true` for that table, or remove the data directory.\n",
wal_path, cnlen, cname);
wo_db_destroy(&DB);
wo_vm_destroy(&VM);
wo_module_free(&mod);
return 2;
}
if (replayed < 0) {
fprintf(stderr, "wovm: %s: replay found corruption beyond a torn tail\n", wal_path); fprintf(stderr, "wovm: %s: replay found corruption beyond a torn tail\n", wal_path);
wo_db_destroy(&DB); wo_db_destroy(&DB);
wo_vm_destroy(&VM); wo_vm_destroy(&VM);

138
scripts/residency-accept.sh Executable file
View file

@ -0,0 +1,138 @@
#!/usr/bin/env bash
# scripts/residency-accept.sh — databasev2 2's gate: per-table storage.
#
# The corpus proves the in-process half (tests/corpus/run/table-volatile-inprocess,
# .../table-residency-legal, .../compile-fail/table-durable-ref-volatile). This
# script proves the half a single process cannot observe:
# * a volatile table is EMPTY after a restart while its durable sibling replays
# * volatile inserts write ZERO bytes to the WAL — measured, not asserted,
# because the file is fallocate'd to 1 MiB up front so its SIZE proves
# nothing; what is measured is the non-zero prefix actually written
# * a mode mismatch (log holds records for a table the source now declares
# volatile) REFUSES to start, exits 2, and names the class
# * the compile-time refusals still fire
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
WOC="compiler/_build/default/bin/woc"
WOVM="runtime/wovm"
pass=0; fail=0
ok() { echo "ok $1"; pass=$((pass + 1)); }
bad() { echo "FAIL $1 -- $2"; fail=$((fail + 1)); }
if [[ ! -x "$WOC" || ! -x "$WOVM" ]]; then
echo "residency-accept: build woc and wovm first (just woc-build && just wovm-build)" >&2
exit 1
fi
WORK="$(mktemp -d "${TMPDIR:-/tmp}/residency-accept.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
# non-zero prefix of a fallocate'd WAL = bytes actually written
wal_bytes() {
python3 -c "import sys;d=open(sys.argv[1],'rb').read();print(len(d.rstrip(b'\x00')))" "$1"
}
# ---- 1. restart: durable replays, volatile does not -----------------------
cat > "$WORK/mix.wo" <<'EOF'
@table(name: "kept", index: [k])
class Kept { k: Text }
@table(name: "scratch", durable: false, index: [k])
class Scratch { k: Text }
fn main(args: multi Text) -> Int {
if len(args) > 0 and args[0] == "seed" {
insert Kept { k: "a" };
insert Scratch { k: "b" };
return 0;
}
let nk = 0;
for x in from r in Kept select r { nk = nk + 1; }
let ns = 0;
for x in from r in Scratch select r { ns = ns + 1; }
print("kept=${nk} scratch=${ns}");
return 0;
}
EOF
if "$WOC" --emit "$WORK/mix.wo" -o "$WORK/mix.wob" 2>"$WORK/e"; then
mkdir -p "$WORK/d1"
WO_DATA="$WORK/d1" "$WOVM" "$WORK/mix.wob" seed >/dev/null 2>&1
got="$(WO_DATA="$WORK/d1" "$WOVM" "$WORK/mix.wob" 2>&1)"
[[ "$got" == "kept=1 scratch=0" ]] \
&& ok "restart: durable row replays, volatile row is gone" \
|| bad "restart: durable replays, volatile gone" "got: $got"
else
bad "restart fixture compiles" "$(head -1 "$WORK/e")"
fi
# ---- 2. the write-path saving, measured ------------------------------------
cat > "$WORK/only.wo" <<'EOF'
@table(name: "dur", index: [k])
class Dur { k: Text }
@table(name: "vol", durable: false, index: [k])
class Vol { k: Text }
fn main(args: multi Text) -> Int {
let n = 0;
while n < 50 {
if args[0] == "dur" { insert Dur { k: "x" }; } else { insert Vol { k: "x" }; }
n = n + 1;
}
return 0;
}
EOF
if "$WOC" --emit "$WORK/only.wo" -o "$WORK/only.wob" 2>"$WORK/e"; then
for m in dur vol; do
mkdir -p "$WORK/w_$m"
WO_DATA="$WORK/w_$m" "$WOVM" "$WORK/only.wob" "$m" >/dev/null 2>&1
done
db="$(wal_bytes "$WORK/w_dur/shard-0.wal")"
vb="$(wal_bytes "$WORK/w_vol/shard-0.wal")"
[[ "$vb" -eq 0 ]] \
&& ok "50 volatile inserts write 0 WAL bytes (durable wrote $db)" \
|| bad "volatile inserts write nothing" "volatile wrote $vb bytes"
[[ "$db" -gt 0 ]] \
&& ok "50 durable inserts do write to the WAL ($db bytes)" \
|| bad "durable inserts still log" "durable wrote $db bytes"
else
bad "measurement fixture compiles" "$(head -1 "$WORK/e")"
fi
# ---- 3. mode mismatch refuses, exits 2, names the class --------------------
printf '@table(name: "orders", index: [k])\nclass Orders { k: Text }\nfn main() -> Int { insert Orders { k: "a" }; return 0; }\n' > "$WORK/wasdur.wo"
printf '@table(name: "orders", durable: false, index: [k])\nclass Orders { k: Text }\nfn main() -> Int { print("started"); return 0; }\n' > "$WORK/nowvol.wo"
if "$WOC" --emit "$WORK/wasdur.wo" -o "$WORK/a.wob" 2>/dev/null \
&& "$WOC" --emit "$WORK/nowvol.wo" -o "$WORK/b.wob" 2>/dev/null; then
mkdir -p "$WORK/d3"
WO_DATA="$WORK/d3" "$WOVM" "$WORK/a.wob" >/dev/null 2>&1
out="$(WO_DATA="$WORK/d3" "$WOVM" "$WORK/b.wob" 2>&1)"; rc=$?
[[ $rc -eq 2 ]] \
&& ok "mode mismatch exits 2 (refuses to start)" \
|| bad "mode mismatch exits 2" "exit=$rc"
grep -q 'Orders' <<<"$out" \
&& ok "mode mismatch names the offending class" \
|| bad "mode mismatch names the class" "got: $out"
grep -q 'corruption' <<<"$out" \
&& bad "mismatch is not reported as corruption" "got: $out" \
|| ok "mode mismatch is not misreported as corruption"
else
bad "mismatch fixtures compile" "compile failed"
fi
# ---- 4. the compile-time refusals still fire ------------------------------
printf '@table(name: "x", durable: false, resident: keys)\nclass X { k: Text }\nfn main() -> Int { return 0; }\n' > "$WORK/combo.wo"
# NOTE: capture, then grep. `woc | grep` under `set -o pipefail` returns
# woc's exit 1 (it reports diagnostics) even when grep matched, which made
# both of these checks fail while the compiler was behaving correctly.
combo_out="$("$WOC" "$WORK/combo.wo" 2>&1)"
grep -q 'WO-E102' <<<"$combo_out" \
&& ok "durable:false + resident:keys is WO-E102" \
|| bad "combination refused" "got: $combo_out"
printf '@table(name: "s", durable: false)\nclass S { t: Text }\n@table(name: "o")\nclass O { s: ref S }\nfn main() -> Int { return 0; }\n' > "$WORK/dref.wo"
dref_out="$("$WOC" "$WORK/dref.wo" 2>&1)"
grep -q 'WO-E224' <<<"$dref_out" \
&& ok "durable ref into a volatile table is WO-E224" \
|| bad "dangling ref refused" "got: $dref_out"
echo
echo "residency-accept: $((pass + fail)) checks, $fail failures"
[[ $fail -eq 0 ]] || exit 1

View file

@ -0,0 +1,4 @@
rows 2
unique refused
who asha token t1
who asha token t2

View file

@ -0,0 +1,30 @@
-- databasev2 2: a `durable: false` table is a FULL table for the life of the
-- process — same indexes, same @unique enforcement, same FK restrict, same
-- query surface. Only its survival differs, and that cannot be observed from
-- inside one process, so this fixture pins the in-process half. The restart
-- half is scripts/residency-accept.sh.
@table(name: "sessions", durable: false, index: [who])
class Session {
token: Text @unique
who: Text
}
fn main() -> Int {
insert Session { token: "t1", who: "asha" };
insert Session { token: "t2", who: "asha" };
let n = 0;
for s in from x in Session select x { n = n + 1; }
print("rows ${n}");
-- @unique still bites on a volatile table
let dup = try insert Session { token: "t1", who: "eve" } catch (e) nil;
if dup == nil { print("unique refused"); }
-- the index-backed probe still works
for s in from x in Session where x.who == "asha" order by x.token select x {
print("who ${s.who} token ${s.token}");
}
return 0;
}