feat(db-bench): replay baseline — boot cost tracks history, not data

Closes the last gap in databasev2 1; gives databasev2 3 its "before".

- `boot` mode: does NOTHING. WO_DATA replay runs before main, so a mode
  with no work measures replay plus a fixed startup
- `replayseed N M`: N inserts + M updates — same live rows, longer log
- `replay` leg: empty-store startup floor measured and SUBTRACTED, then
  two shapes timed, median of 3 boots each
- premise check: updates must actually append WAL records, else the two
  shapes are one measurement and the penalty means nothing
- WAL bytes = non-zero prefix, never file size (fallocate'd to 1 MiB)
- per-record cost stored in NANOseconds: as us it rounded 5.5 and 5.3 to
  6 and 5, too coarse for the number a checkpoint exists to improve
- 148 checks, 0 failures; gate bites on a doctored ns_per_record

Measured — same 20 000 live rows, different history:

- 20 000 records:  980 035 B WAL, 110 ms replay, 5.5 us/record
- 40 000 records: 1 960 035 B WAL, 211 ms replay, 5.3 us/record
- 1.9x boot cost for an IDENTICAL dataset; per-record cost flat, so
  replay is linear in records not rows
- extrapolated: 10M records ~55 s of boot, 100M ~9 min

- databasev2 3 correction: it planned to use "22's aged-store replay
  numbers", which never existed — 22 proved restart correctness, never
  timed it
- databasev2 3 hazard recorded: compaction rewrites the log and moves
  every record, so it invalidates every `resident: keys` offset — an
  arbitrary byte in a rewritten file, not stale-but-readable
- databasev2 1 -> status: done

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-27 21:25:24 +02:00
parent a873cf7331
commit 5b1a8c96a1
8 changed files with 432 additions and 124 deletions

View file

@ -8,15 +8,15 @@
},
"ceiling.rows_recovered": {
"dir": "lower",
"floor": 159828,
"floor": 159744,
"tolerance_pct": 100,
"value": 39957
"value": 39936
},
"durable.s1.mixread.ops_sec": {
"dir": "higher",
"floor": 2239,
"floor": 2237,
"tolerance_pct": 50,
"value": 8957
"value": 8949
},
"durable.s1.mixread.p50us": {
"dir": "lower",
@ -34,25 +34,25 @@
"dir": "higher",
"floor": 248,
"tolerance_pct": 50,
"value": 995
"value": 994
},
"durable.s1.mixwrite.p50us": {
"dir": "lower",
"floor": 828,
"floor": 820,
"tolerance_pct": 50,
"value": 207
"value": 205
},
"durable.s1.mixwrite.p99us": {
"dir": "lower",
"floor": 868,
"floor": 872,
"tolerance_pct": 50,
"value": 217
"value": 218
},
"durable.s1.query.ops_sec": {
"dir": "higher",
"floor": 215517,
"floor": 335570,
"tolerance_pct": 50,
"value": 862068
"value": 1342281
},
"durable.s1.query.p50us": {
"dir": "lower",
@ -68,9 +68,9 @@
},
"durable.s1.read.ops_sec": {
"dir": "higher",
"floor": 221827,
"floor": 347705,
"tolerance_pct": 50,
"value": 887311
"value": 1390820
},
"durable.s1.read.p50us": {
"dir": "lower",
@ -86,27 +86,27 @@
},
"durable.s1.seed.ops_sec": {
"dir": "higher",
"floor": 1128,
"floor": 1103,
"tolerance_pct": 15,
"value": 4515
"value": 4415
},
"durable.s1.seed.p50us": {
"dir": "lower",
"floor": 832,
"floor": 828,
"tolerance_pct": 15,
"value": 208
"value": 207
},
"durable.s1.seed.p99us": {
"dir": "lower",
"floor": 2180,
"floor": 2092,
"tolerance_pct": 15,
"value": 545
"value": 523
},
"durable.s1.write.ops_sec": {
"dir": "higher",
"floor": 1141,
"floor": 1155,
"tolerance_pct": 15,
"value": 4565
"value": 4620
},
"durable.s1.write.p50us": {
"dir": "lower",
@ -116,51 +116,51 @@
},
"durable.s1.write.p99us": {
"dir": "lower",
"floor": 2256,
"floor": 1948,
"tolerance_pct": 15,
"value": 564
"value": 487
},
"durable.sN.mixread.ops_sec": {
"dir": "higher",
"floor": 1117,
"floor": 1112,
"tolerance_pct": 50,
"value": 4468
"value": 4450
},
"durable.sN.mixread.p50us": {
"dir": "lower",
"floor": 240,
"floor": 236,
"tolerance_pct": 50,
"value": 60
"value": 59
},
"durable.sN.mixread.p99us": {
"dir": "lower",
"floor": 19900,
"floor": 13100,
"tolerance_pct": 50,
"value": 4975
"value": 3275
},
"durable.sN.mixwrite.ops_sec": {
"dir": "higher",
"floor": 124,
"floor": 123,
"tolerance_pct": 50,
"value": 496
"value": 494
},
"durable.sN.mixwrite.p50us": {
"dir": "lower",
"floor": 1104,
"floor": 1160,
"tolerance_pct": 50,
"value": 276
"value": 290
},
"durable.sN.mixwrite.p99us": {
"dir": "lower",
"floor": 1928,
"floor": 2944,
"tolerance_pct": 50,
"value": 482
"value": 736
},
"durable.sN.query.ops_sec": {
"dir": "higher",
"floor": 331125,
"floor": 287356,
"tolerance_pct": 50,
"value": 1324503
"value": 1149425
},
"durable.sN.query.p50us": {
"dir": "lower",
@ -176,9 +176,9 @@
},
"durable.sN.read.ops_sec": {
"dir": "higher",
"floor": 341064,
"floor": 192752,
"tolerance_pct": 50,
"value": 1364256
"value": 771010
},
"durable.sN.read.p50us": {
"dir": "lower",
@ -190,31 +190,31 @@
"dir": "lower",
"floor": 100,
"tolerance_pct": 50,
"value": 1
"value": 2
},
"durable.sN.seed.ops_sec": {
"dir": "higher",
"floor": 1125,
"floor": 1142,
"tolerance_pct": 50,
"value": 4502
"value": 4571
},
"durable.sN.seed.p50us": {
"dir": "lower",
"floor": 828,
"floor": 836,
"tolerance_pct": 50,
"value": 207
"value": 209
},
"durable.sN.seed.p99us": {
"dir": "lower",
"floor": 2624,
"floor": 1916,
"tolerance_pct": 50,
"value": 656
"value": 479
},
"durable.sN.write.ops_sec": {
"dir": "higher",
"floor": 1150,
"floor": 1010,
"tolerance_pct": 50,
"value": 4600
"value": 4040
},
"durable.sN.write.p50us": {
"dir": "lower",
@ -224,9 +224,9 @@
},
"durable.sN.write.p99us": {
"dir": "lower",
"floor": 2348,
"floor": 1996,
"tolerance_pct": 50,
"value": 587
"value": 499
},
"growth.available": {
"dir": "lower",
@ -272,9 +272,9 @@
},
"growth.int.noswap.rss_kb": {
"dir": "lower",
"floor": 23920,
"floor": 23968,
"tolerance_pct": 100,
"value": 5980
"value": 5992
},
"growth.int.swap.bytes_per_row": {
"dir": "lower",
@ -314,9 +314,9 @@
},
"growth.int.swap.rss_kb": {
"dir": "lower",
"floor": 23920,
"floor": 23984,
"tolerance_pct": 100,
"value": 5980
"value": 5996
},
"growth.text.noswap.bytes_per_row": {
"dir": "lower",
@ -356,9 +356,9 @@
},
"growth.text.noswap.rss_kb": {
"dir": "lower",
"floor": 41184,
"floor": 41216,
"tolerance_pct": 100,
"value": 10296
"value": 10304
},
"growth.text.swap.bytes_per_row": {
"dir": "lower",
@ -398,15 +398,15 @@
},
"growth.text.swap.rss_kb": {
"dir": "lower",
"floor": 41200,
"floor": 41216,
"tolerance_pct": 100,
"value": 10300
"value": 10304
},
"ram.s1.mixread.ops_sec": {
"dir": "higher",
"floor": 2236,
"tolerance_pct": 50,
"value": 8945
"value": 8947
},
"ram.s1.mixread.p50us": {
"dir": "lower",
@ -424,7 +424,7 @@
"dir": "higher",
"floor": 248,
"tolerance_pct": 50,
"value": 993
"value": 994
},
"ram.s1.mixwrite.p50us": {
"dir": "lower",
@ -440,15 +440,15 @@
},
"ram.s1.msgrate.msgs_sec": {
"dir": "higher",
"floor": 392834,
"floor": 419322,
"tolerance_pct": 15,
"value": 3142677
"value": 3354579
},
"ram.s1.query.ops_sec": {
"dir": "higher",
"floor": 214592,
"floor": 324675,
"tolerance_pct": 50,
"value": 858369
"value": 1298701
},
"ram.s1.query.p50us": {
"dir": "lower",
@ -460,13 +460,13 @@
"dir": "lower",
"floor": 100,
"tolerance_pct": 50,
"value": 2
"value": 1
},
"ram.s1.read.ops_sec": {
"dir": "higher",
"floor": 226860,
"floor": 332889,
"tolerance_pct": 50,
"value": 907441
"value": 1331557
},
"ram.s1.read.p50us": {
"dir": "lower",
@ -478,19 +478,19 @@
"dir": "lower",
"floor": 100,
"tolerance_pct": 50,
"value": 2
"value": 1
},
"ram.s1.seed.ops_sec": {
"dir": "higher",
"floor": 273373,
"floor": 375939,
"tolerance_pct": 15,
"value": 1093493
"value": 1503759
},
"ram.s1.seed.p50us": {
"dir": "lower",
"floor": 100,
"tolerance_pct": 15,
"value": 1
"value": 0
},
"ram.s1.seed.p99us": {
"dir": "lower",
@ -500,9 +500,9 @@
},
"ram.s1.write.ops_sec": {
"dir": "higher",
"floor": 159134,
"floor": 272628,
"tolerance_pct": 15,
"value": 636537
"value": 1090512
},
"ram.s1.write.p50us": {
"dir": "lower",
@ -514,55 +514,55 @@
"dir": "lower",
"floor": 100,
"tolerance_pct": 15,
"value": 3
"value": 2
},
"ram.sN.mixread.ops_sec": {
"dir": "higher",
"floor": 2239,
"floor": 2241,
"tolerance_pct": 50,
"value": 8958
"value": 8964
},
"ram.sN.mixread.p50us": {
"dir": "lower",
"floor": 232,
"floor": 228,
"tolerance_pct": 50,
"value": 58
"value": 57
},
"ram.sN.mixread.p99us": {
"dir": "lower",
"floor": 1032,
"floor": 1412,
"tolerance_pct": 50,
"value": 258
"value": 353
},
"ram.sN.mixwrite.ops_sec": {
"dir": "higher",
"floor": 248,
"floor": 249,
"tolerance_pct": 50,
"value": 995
"value": 996
},
"ram.sN.mixwrite.p50us": {
"dir": "lower",
"floor": 256,
"floor": 252,
"tolerance_pct": 50,
"value": 64
"value": 63
},
"ram.sN.mixwrite.p99us": {
"dir": "lower",
"floor": 328,
"floor": 280,
"tolerance_pct": 50,
"value": 82
"value": 70
},
"ram.sN.msgrate.msgs_sec": {
"dir": "higher",
"floor": 229885,
"floor": 214795,
"tolerance_pct": 50,
"value": 1839080
"value": 1718360
},
"ram.sN.query.ops_sec": {
"dir": "higher",
"floor": 337837,
"floor": 331125,
"tolerance_pct": 50,
"value": 1351351
"value": 1324503
},
"ram.sN.query.p50us": {
"dir": "lower",
@ -578,9 +578,9 @@
},
"ram.sN.read.ops_sec": {
"dir": "higher",
"floor": 342935,
"floor": 340599,
"tolerance_pct": 50,
"value": 1371742
"value": 1362397
},
"ram.sN.read.p50us": {
"dir": "lower",
@ -596,9 +596,9 @@
},
"ram.sN.seed.ops_sec": {
"dir": "higher",
"floor": 295159,
"floor": 353606,
"tolerance_pct": 50,
"value": 1180637
"value": 1414427
},
"ram.sN.seed.p50us": {
"dir": "lower",
@ -614,9 +614,9 @@
},
"ram.sN.write.ops_sec": {
"dir": "higher",
"floor": 289017,
"floor": 290697,
"tolerance_pct": 50,
"value": 1156069
"value": 1162790
},
"ram.sN.write.p50us": {
"dir": "lower",
@ -632,27 +632,27 @@
},
"randread.collapse_x": {
"dir": "lower",
"floor": 1136,
"floor": 1172,
"tolerance_pct": 100,
"value": 284
"value": 293
},
"randread.overcap.filled_rss_kb": {
"dir": "lower",
"floor": 25024,
"floor": 25680,
"tolerance_pct": 100,
"value": 6256
"value": 6420
},
"randread.overcap.ops_sec": {
"dir": "higher",
"floor": 1669,
"floor": 1665,
"tolerance_pct": 100,
"value": 6676
"value": 6661
},
"randread.overcap.read_p50us": {
"dir": "lower",
"floor": 548,
"floor": 556,
"tolerance_pct": 100,
"value": 137
"value": 139
},
"randread.overcap.read_p99us": {
"dir": "lower",
@ -662,15 +662,15 @@
},
"randread.resident.filled_rss_kb": {
"dir": "lower",
"floor": 54000,
"floor": 54080,
"tolerance_pct": 100,
"value": 13500
"value": 13520
},
"randread.resident.ops_sec": {
"dir": "higher",
"floor": 475556,
"floor": 488424,
"tolerance_pct": 100,
"value": 1902225
"value": 1953697
},
"randread.resident.read_p50us": {
"dir": "lower",
@ -683,5 +683,65 @@
"floor": 100,
"tolerance_pct": 100,
"value": 1
},
"replay.history.ms": {
"dir": "lower",
"floor": 844,
"tolerance_pct": 100,
"value": 211
},
"replay.history.ns_per_record": {
"dir": "lower",
"floor": 21068,
"tolerance_pct": 100,
"value": 5267
},
"replay.history.records": {
"dir": "lower",
"floor": 160000,
"tolerance_pct": 100,
"value": 40000
},
"replay.history.wal_bytes": {
"dir": "lower",
"floor": 7840140,
"tolerance_pct": 100,
"value": 1960035
},
"replay.history_penalty_x": {
"dir": "lower",
"floor": 100,
"tolerance_pct": 100,
"value": 1.9
},
"replay.inserts.ms": {
"dir": "lower",
"floor": 444,
"tolerance_pct": 100,
"value": 111
},
"replay.inserts.ns_per_record": {
"dir": "lower",
"floor": 22120,
"tolerance_pct": 100,
"value": 5530
},
"replay.inserts.records": {
"dir": "lower",
"floor": 80000,
"tolerance_pct": 100,
"value": 20000
},
"replay.inserts.wal_bytes": {
"dir": "lower",
"floor": 3920140,
"tolerance_pct": 100,
"value": 980035
},
"replay.startup_ms": {
"dir": "lower",
"floor": 100,
"tolerance_pct": 100,
"value": 3
}
}

View file

@ -467,7 +467,7 @@ fn usage() -> Int {
print_err("usage: db-bench <mode>");
print_err(" all N | seed N | read N | query N | write N | wal N");
print_err(" mix N C | msgrate N | growth N int|text | growth-verify");
print_err(" randread N R");
print_err(" randread N R | replayseed N M | boot");
print_err(" verify | verify-acked M");
return 2;
}
@ -531,6 +531,41 @@ fn self_rss_kb() -> Int {
-- larger than RAM. No RNG in the language and none needed: i*2654435761 mod n
-- is a Weyl sequence, deterministic and spread, so the two legs read the SAME
-- key order and only residency differs.
-- databasev2 1, for iteration 3: the replay "before".
--
-- `boot` does NOTHING. That is the point: with WO_DATA set the runtime replays
-- the whole WAL before main runs, so the process's wall time IS the replay cost
-- plus a fixed startup. Any mode that touches rows would mix its own work into
-- the number.
fn boot_mode() -> Int {
print("booted");
return 0;
}
-- Build a store with N live rows and N+M total WAL records: M updates on top of
-- N inserts. The live dataset is IDENTICAL for any M -- only the history grows.
-- That is iteration 3's whole case: with no checkpoint, boot replays HISTORY,
-- not data, so a long-lived row that has been updated a thousand times costs a
-- thousand records at every boot forever.
fn replayseed_mode(n: Int, m: Int) -> Int {
let bref = insert Bucket { tag: "replay" };
let i = 1;
while i <= n {
insert Item { k: i, v: item_v(i), bucket: bref };
i = i + 1;
}
let j = 0;
while j < m {
let key = 1 + (j * 2654435761) % n;
for r in from x in Item where x.k == key take 1 select x {
r.v = r.v + 1;
}
j = j + 1;
}
print("replayseeded ${n} ${m}");
return 0;
}
fn randread_mode(n: Int, r: Int) -> Int {
let bref = insert Bucket { tag: "randread" };
let i = 1;
@ -648,6 +683,9 @@ fn main(args: multi Text) -> Int {
if args[0] == "growth-verify" {
return growth_verify();
}
if args[0] == "boot" {
return boot_mode();
}
if len(args) < 2 {
return usage();
}
@ -686,6 +724,17 @@ fn main(args: multi Text) -> Int {
}
return growth_mode(n, args[2]);
}
if args[0] == "replayseed" {
if len(args) < 3 {
return usage();
}
let mm = parse_int(args[2]);
if mm == nil or mm < 0 {
print_err("db-bench: <m> must be zero or more");
return 2;
}
return replayseed_mode(n, mm);
}
if args[0] == "randread" {
if len(args) < 3 {
return usage();

View file

@ -189,3 +189,30 @@ read path rather than inherit this figure.
Gated as `db-bench`'s `randread` leg, which gates the **ratio** — the absolute
reads/sec of the over-cap half is the box's swap device, while the factor between
two runs differing only in their cap is the engine's.
### Replay: boot cost tracks history, not data
Two stores with the **same 20 000 live rows** and different history lengths.
Process startup (3.5 ms, empty store) is subtracted, so these are replay:
| Shape | Records | WAL used | Replay | Per record |
| --- | --- | --- | --- | --- |
| N inserts | 20 000 | 980 035 B | **110 ms** | 5.5 µs |
| N inserts + N updates | 40 000 | 1 960 035 B | **211 ms** | 5.3 µs |
**1.9× the boot cost for an identical dataset.** Per-record cost is flat, so
replay is linear in **records**, not rows. An update appends a record and nothing
ever collapses it, so a row updated a thousand times costs a thousand records at
every boot, forever.
Extrapolated at 5.5 µs/record: **10M records ≈ 55 s of boot, 100M ≈ 9 minutes.**
This is the "before" databasev2 3 lacked — `bench/baseline.json` carried no
replay, restart, boot or recovery metric at all, because iteration 22 proved
restart *correctness* and never timed it. Gated as `db-bench`'s `replay` leg:
`replay.inserts.*`, `replay.history.*`, `replay.history_penalty_x`. Per-record
cost is stored in **nanoseconds** — as µs it rounded 5.5 and 5.3 to 6 and 5,
which is too coarse for the one number a checkpoint is meant to improve.
WAL bytes are measured as the file's **non-zero prefix**, never its size: shard
WALs are `fallocate`'d to 1 MiB, so an empty store reports 1048576.

View file

@ -76,8 +76,8 @@ Int-only `Item`; `growth N int|text` in the db-bench sample, reading its OWN
the value *at* a boundary; `growth-verify`, which asserts the survivor of a
crash is a contiguous intact prefix; and two harness legs — four footprint legs
under a rootless cgroup v2 cap, a `ceiling` leg that deliberately dies at the cap
and then replays, and a `randread` leg that reads an oversized table randomly.
133 checks, 0 failures.
and then replays, a `randread` leg that reads an oversized table randomly, and a
`replay` leg that times boot against history length. 148 checks, 0 failures.
**Key findings (measured, not asserted):** per-row footprint is **96.5–100 B**
Int-only and **320.6–324 B** text-heavy — **3.3×**, not the "order of magnitude"
@ -92,7 +92,10 @@ collapse": 900 000 rows inside a 64 MiB cap with swap finished in **148 s
against 150 s uncapped** — ~1%, on a real disk swap file with no zram. Also
measured: **ack-after-fsync holds through an OOM kill** — ~40 000 rows came back
as an intact prefix, no holes, not read as corruption. And the pattern the swap
leg was missing: **random reads over an oversized table collapse 273×.**
leg was missing: **random reads over an oversized table collapse 273×.** Plus
replay: **≈5.5 µs per WAL record**, and **1.9× the boot cost for an identical
live dataset** once each row has been updated once (110 → 211 ms for the same
20 000 rows) — boot replays **history, not data**.
**Learned:** an append-mostly workload never re-touches its cold pages, so swap
costs it nothing — and the opposite pattern was then measured on the same day.
@ -118,8 +121,15 @@ still NOT delivered — `bench/baseline.json` times no replay.
number bounds demand-paged anonymous memory through swap (4 KiB per fault, no
readahead); `pread` through the page cache should beat it, and **the entire value
of `resident: keys` rests on how much** — if it is not materially better than
swapping, the design buys nothing the kernel was not already doing. Still absent:
a replay baseline for iteration 3.
swapping, the design buys nothing the kernel was not already doing.
Iteration 3 now HAS its before, and a correction: it planned to use "22's
aged-store replay numbers", which never existed — 22 proved restart correctness
and never timed it. Also recorded there, before it could be rediscovered late:
**compaction invalidates every `resident: keys` offset**, since it rewrites the
log and moves every record. Not stale-but-readable — an arbitrary byte in a
rewritten file. So compaction cannot be a pure file operation that ignores
in-memory table state.
**`.dev/reference` used:** none. Sources were the kernel's own interfaces —
cgroup v2 `memory.max`/`memory.swap.max`, `/proc/self/status`, `/proc/swaps` and
@ -693,7 +703,7 @@ the language arc as v1 history.
| # | Iteration | State |
| --- | --- | --- |
| 1 | [RAM ceiling: measure the breaking point](databasev2/01-ram-ceiling-measurement.md) | 🔄 **MEASURED 2026-08-27** — `readiness: ready`, `status: in-progress` (iteration 3 replay baseline still undelivered), forks settled, harness landed (**133 checks**). Footprint **96.5–100 B/row** Int vs **320.6–324 B/row** text = **3.3×** (not the "order of magnitude" three docs claimed), read as median-of-marginals because doublings swing a two-point slope 2×. **Both predicted exits were wrong:** table storage has no checked ceiling and is **SIGKILLed** (overcommit lets `malloc` succeed, kernel kills on page touch), and swap is not latency collapse — 900k rows finished **148 s capped-with-swap vs 150 s uncapped**, ~1%, returning 0 while serving from disk. **Ack-after-fsync survives an OOM kill:** ~40 000 rows recovered as an intact prefix, gated as the `ceiling` leg. Also measured: **random reads over an oversized table collapse 273×** (1.85M vs 6 771 reads/s, p99 1 µs vs 487 µs) — so the two access patterns sit ~270× apart under the same pressure, and departure is a **step, not a curve**. Outstanding: iteration 3's replay baseline. Iteration 2's budget dependency is **removed, not satisfied** — there is no "swap onset" to derive it from |
| 1 | [RAM ceiling: measure the breaking point](databasev2/01-ram-ceiling-measurement.md) | ✅ **MEASURED 2026-08-27** — `readiness: ready`, `status: done`; forks settled, harness landed (**148 checks**). Footprint **96.5–100 B/row** Int vs **320.6–324 B/row** text = **3.3×** (not the "order of magnitude" three docs claimed), read as median-of-marginals because doublings swing a two-point slope 2×. **Both predicted exits were wrong:** table storage has no checked ceiling and is **SIGKILLed** (overcommit lets `malloc` succeed, kernel kills on page touch), and swap is not latency collapse — 900k rows finished **148 s capped-with-swap vs 150 s uncapped**, ~1%, returning 0 while serving from disk. **Ack-after-fsync survives an OOM kill:** ~40 000 rows recovered as an intact prefix, gated as the `ceiling` leg. Also measured: **random reads over an oversized table collapse 273×** (1.85M vs 6 771 reads/s, p99 1 µs vs 487 µs) — so the two access patterns sit ~270× apart under the same pressure, and departure is a **step, not a curve**. Replay measured too: **≈5.5 µs/record, 1.9× history penalty** (10M records ≈ 55 s of boot) — iteration 3's missing "before", now gated. Iteration 2's budget dependency is **removed, not satisfied** — there is no "swap onset" to derive it from |
| 2 | [per-table storage: `durable` and `resident`](databasev2/02-table-storage-modes.md) | 🔄 **the language enrichment — the `durable` half is DONE and usable.** Two optional `@table` keys, `durable: true\|false` and `resident: all\|keys`, both defaulting to today's behaviour (all 28 existing declarations compile unchanged, no golden moved). Landed: the grammar, WO-E224 (a durable `ref` into a volatile table is refused), `.wob` v7 carrying both properties in spare `flags` bits, `durable: false` actually skipping the WAL (measured: 50 inserts → 1500 bytes durable, **0** volatile) with a mode-mismatch startup refusal, plus offset capture and read-a-row-from-an-offset. Outstanding: 5c/5d (the id→offset map and rewiring `wo_row_ptr`'s 11 call sites, slab scans and `@unique`/FK across the boundary — not yet written up), the two runtime refusals, and closeout. [spec](../superpowers/specs/2026-08-26-table-residency-design.md) · [plan](../superpowers/plans/2026-08-26-table-residency.md) |
| 3 | [WAL checkpoint](databasev2/03-wal-checkpoint.md) *(was 32)* | ⬜ snapshot + truncate: disk reclaimed, replay bounded |
| 4 | [io_uring group commit](databasev2/04-io-uring-commit.md) *(was 23)* | ⬜ **`readiness: ready` — the one startable iteration in the repo** (four forks confirmed settled 2026-08-20). Close the 66× gap iteration 22 measured (durable 4.5k vs ram 297k inserts/s) |

View file

@ -140,7 +140,7 @@ before its mechanism existed; the history is in
| # | Iteration | Delivers | Needs |
| --- | --- | --- | --- |
| 1 | [RAM ceiling: measure the breaking point](01-ram-ceiling-measurement.md) | 🔄 **measured 2026-08-27**: footprint per shape (3.3× apart), the two silent exits (SIGKILL vs swap-serving-from-disk at ~uncapped speed), and ack-after-fsync surviving an OOM kill. Also measured: the **273× random-read collapse** over an oversized table. Outstanding: a replay baseline | nothing; extends iteration 22's harness |
| 1 | [RAM ceiling: measure the breaking point](01-ram-ceiling-measurement.md) | 🔄 **measured 2026-08-27**: footprint per shape (3.3× apart), the two silent exits (SIGKILL vs swap-serving-from-disk at ~uncapped speed), and ack-after-fsync surviving an OOM kill. Also measured: the **273× random-read collapse** over an oversized table, and replay at **≈5.5 µs/record with a 1.9× history penalty** — iteration 3's "before" | nothing; extends iteration 22's harness |
| 2 | [per-table storage](02-table-storage-modes.md) | the grammar: `durable: true\|false` and `resident: all\|keys`, per table, replacing the global `WO_DATA` all-or-nothing. **In progress — the `durable` half is done** | 1 for the budget default |
| 3 | [WAL checkpoint](03-wal-checkpoint.md) *(was language 32)* | snapshot + truncate: disk reclaimed, replay bounded | 4 composes |
| 4 | [io_uring group commit](04-io-uring-commit.md) *(was language 23)* | close the 66× durable/RAM write gap (4.5k vs 297k inserts/s) | the arc (landed) |

View file

@ -1,7 +1,7 @@
---
track: databasev2
iteration: "1"
status: in-progress
status: done
readiness: ready
---
@ -114,10 +114,10 @@ workload. Reading *randomly* across a table larger than the cap collapses
| footprint metric = **median of marginals**, doublings counted separately | ✅ |
| `ceiling` leg: dies at the cap, then replay must be intact | ✅ gated |
| `randread` leg: control vs over-cap, same key order | ✅ gated |
| baseline + tolerance policy | ✅ 133 checks; footprint at ±10%, kill-timing metrics at ±100% |
| baseline + tolerance policy | ✅ 148 checks; footprint at ±10%, kill-timing metrics at ±100% |
| `perf-targets.md` §5 | ✅ |
| **resident-footprint fraction for [iteration 2](02-table-storage-modes.md)** | ⬜ **not delivered — the premise it rested on is false**, see Outstanding |
| **replay/restart baseline for [iteration 3](03-wal-checkpoint.md)** | ⬜ not delivered |
| `boot` + `replayseed N M` + the `replay` leg — [iteration 3](03-wal-checkpoint.md)'s "before" | ✅ **≈5.5 µs/record, 1.9× history penalty** |
| `randread N R` + the `randread` leg — random reads over an oversized table | ✅ **273x collapse measured** |
## Measured
@ -174,6 +174,24 @@ while swap-in does not. **That is a hypothesis, not a result.** The honest
reading is that 273× bounds what *swapping* costs, and iteration 2 must measure
its own read path rather than inherit this number.
Replay, and it is iteration 3's whole case — same live dataset, different
history length:
| Shape | Records | WAL | Replay | Per record |
| --- | --- | --- | --- | --- |
| N inserts | 20 000 | 980 035 B | **110 ms** | 5.5 µs |
| N inserts + N updates | 40 000 | 1 960 035 B | **211 ms** | 5.3 µs |
**20 000 live rows either way. 1.9× the boot cost.** Per-record cost is flat
(5.5 vs 5.3 µs), so replay is linear in **records, not rows** — boot replays
*history*. A row updated a thousand times costs a thousand records at every boot,
forever, because nothing ever collapses them. Process startup (3.5 ms on an
empty store) is subtracted, so these are replay, not spawn.
Extrapolated at 5.5 µs/record: **10M records ≈ 55 s of boot, 100M ≈ 9 minutes.**
That is the number [iteration 3](03-wal-checkpoint.md) exists to bound, and it
had no "before" until now.
**The finding that matters most is the swap leg succeeding.** It did not fail,
did not warn, and returned 0. A deployment in that state looks healthy while
serving from disk. That is the exit with no error signal, and it is why
@ -211,11 +229,15 @@ Met:
the degradation is quantified. ✅ **273× throughput, ~480× p99**, both legs
reading the same key order with all reads resolving. This closes the gap the
swap leg left, and it is the pattern `resident: keys` creates.
- **Given** a store with history, **when** it boots, **then** replay cost is
recorded so iteration 3 has a before. ✅ **≈5.5 µs/record**, flat across
shapes, and **1.9× boot cost for an identical dataset** once each row has been
updated once. Startup subtracted via an empty store.
Outstanding:
- **The resident-footprint fraction for iteration 2's budget default. NOT
delivered, and the premise is false.** It was to be derived from the
delivered, and the premise is false** — a finding, not a gap. It was to be derived from the
swap-onset point — but there is no onset: swap-off jumps straight from
working to SIGKILL, and swap-on shows no degradation to detect an onset in.
**Iteration 2 must pick its budget on other grounds** (host RAM fraction, or
@ -228,9 +250,7 @@ Outstanding:
approach their 512 MiB cap, but the departure itself is measured by the
`randread` leg as a **step, not a curve** — 1 µs resident, 487 µs over-cap.
There is no gentle departure to find; residency is close to binary.
- **A replay/restart baseline for iteration 3.** Not delivered; `growth` exercises
`WO_DATA` but nothing times replay. Cheap to add, still absent from
`bench/baseline.json`.
- Nothing else. Both remaining gaps closed 2026-08-27.
## Out Of Scope

View file

@ -56,6 +56,14 @@ chain: 6
- **Given** the iteration-22 restart benchmark re-run after checkpoint
lands, **when** replay time is measured on an aged store, **then**
the bounded-replay improvement is recorded as a before/after delta.
**The "before" now EXISTS** (databasev2 1, 2026-08-27): `db-bench`'s
`replay` leg measures **≈5.5 µs per WAL record**, and — the number
this iteration is actually about — **1.9× the boot cost for an
identical live dataset** once the same rows have been updated once
each (20 000 rows: 110 ms at 20 000 records, 211 ms at 40 000). Boot
cost tracks **history, not data**, which is exactly what a checkpoint
collapses. Metrics: `replay.inserts.*`, `replay.history.*`,
`replay.history_penalty_x`.
- **Given** writes arriving while a checkpoint runs (the DB actor
serializes statements; the checkpoint must not stall them beyond the
stated budget), **when** the mixed load completes, **then** every ack
@ -101,6 +109,29 @@ Forks the spec must settle:
## Proposed Solution
Brainstorm → spec → plan after 23 lands (the write path it composes
with) using 22's aged-store replay numbers as the policy input; extend
with). **Correction (2026-08-27):** this said "using 22's aged-store
replay numbers as the policy input", but iteration 22 produced no such
numbers — it proved restart *correctness* and never timed it, and
`bench/baseline.json` carried zero replay metrics until databasev2 1
added them. The policy input is the `replay` leg's ≈5.5 µs/record and
its 1.9× history penalty. Extend
`04-db-binding.md`'s WAL section with the snapshot format the way the
record grammar is documented today.
## Hazard: compaction invalidates every `resident: keys` offset
Surfaced while refining this iteration and recorded here so it is not
rediscovered late. [Iteration 2](02-table-storage-modes.md)'s
`resident: keys` stores a **WAL byte offset per row** and reads the row
back with `pread` at that offset. Compaction — whichever of the two
shapes below wins — **rewrites the log and moves every record**, so
every stored offset becomes wrong. Not stale-but-readable: pointing at
an arbitrary byte in a rewritten file, which is a correctness fault,
not a performance one.
So the two iterations are coupled and the coupling has to be designed,
not discovered: either compaction rebuilds the offset map as it
rewrites (it knows both addresses, so this is the cheap direction), or
the snapshot persists the map and compaction is forbidden while any
`resident: keys` table is live. **The first is almost certainly right**,
but it means compaction cannot be written as a pure file operation that
ignores in-memory table state.

View file

@ -234,6 +234,7 @@ def tolerance_for(key):
if key.startswith("growth."): return 100
if key.startswith("ceiling."): return 100
if key.startswith("randread."): return 100
if key.startswith("replay."): return 100
if ".mixread." in key or ".mixwrite." in key: return 50
if ".sN." in key: return 50
if ".read." in key or ".query." in key: return 50
@ -379,6 +380,8 @@ RAND_N = 60000 if QUICK else 200000
RAND_R = 20000 if QUICK else 40000
RAND_CAP_MB = 6 if QUICK else 14 # over-cap: holds roughly a third of the rows
RAND_FIT_MB = 256 # control: same mechanism, cap simply does not bind
REPLAY_N = 20000 if QUICK else 100000
REPLAY_BOOTS = 3 # median of 3; boot is timed, so noise matters
def ceiling(metrics):
"""The ceiling itself, and the durability claim across it.
@ -513,6 +516,113 @@ def randread(metrics):
f"({res['resident']} -> {res['overcap']} reads/sec)")
def wal_used(data_dir):
"""Bytes actually written across the store's WAL files.
The non-zero prefix, NOT the file size: shard WALs are fallocate'd to
1 MiB up front, so getsize reports 1048576 for an empty store and proves
nothing. Same reason scripts/residency-accept.sh measures it this way."""
total = 0
for name in sorted(os.listdir(data_dir)):
with open(os.path.join(data_dir, name), "rb") as f:
total += len(f.read().rstrip(b"\x00"))
return total
def time_boot(data_dir):
"""Median wall-clock ms of `boot`, which does nothing at all.
With WO_DATA set the runtime replays the entire WAL BEFORE main runs, so a
mode that does no work measures replay plus a fixed process startup. Any
mode that touched rows would fold its own cost in. Median of REPLAY_BOOTS
because this is wall-clock on a shared box."""
env = dict(os.environ); env["WO_DATA"] = data_dir
samples = []
for _ in range(REPLAY_BOOTS):
t0 = time.monotonic()
pr = subprocess.run([BIN, "boot"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, env=env, timeout=900)
if pr.returncode != 0:
return None
samples.append((time.monotonic() - t0) * 1000.0)
return sorted(samples)[len(samples) // 2]
def replay(metrics):
"""The replay baseline databasev2 3 has no "before" for.
bench/baseline.json carried zero metrics for replay, restart, boot or
recovery. Iteration 22 proved restart CORRECTNESS; it never timed it, so
iteration 3's "bounded replay" claim had nothing to measure against.
Two shapes with the SAME live dataset and different history lengths:
- inserts: N inserts, N records
- history: N inserts + N updates, 2N records, same N live rows
The live data is identical; only the log is longer. That is iteration 3's
entire case: with no checkpoint, boot replays HISTORY rather than DATA, so a
row updated a thousand times costs a thousand records at every boot,
forever. history_penalty_x is the headline -- the boot cost of history that
a checkpoint would collapse.
Startup is subtracted using an empty store, so the reported ms is replay,
not process spawn."""
empty = os.path.join(ROOT, "bench", f"tmp.{os.getpid()}.replay.empty")
shutil.rmtree(empty, ignore_errors=True); os.makedirs(empty, exist_ok=True)
base_ms = time_boot(empty)
shutil.rmtree(empty, ignore_errors=True)
if base_ms is None:
bad("replay: empty-store boot failed", "cannot establish the startup floor")
return
metrics["replay.startup_ms"] = int(round(base_ms))
ok(f"replay: empty-store startup floor {base_ms:.1f} ms (subtracted below)")
res = {}
for legname, updates in (("inserts", 0), ("history", REPLAY_N)):
data = os.path.join(ROOT, "bench", f"tmp.{os.getpid()}.replay.{legname}")
shutil.rmtree(data, ignore_errors=True); os.makedirs(data, exist_ok=True)
env = dict(os.environ); env["WO_DATA"] = data
sr = subprocess.run([BIN, "replayseed", str(REPLAY_N), str(updates)],
stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
env=env, timeout=900)
key = f"replay.{legname}"
if sr.returncode != 0:
bad(f"{key}: seed failed", f"rc={sr.returncode}")
shutil.rmtree(data, ignore_errors=True); return
wal = wal_used(data)
boot_ms = time_boot(data)
shutil.rmtree(data, ignore_errors=True)
if boot_ms is None:
bad(f"{key}: boot failed", "replay did not complete")
return
records = REPLAY_N + updates
rep_ms = max(boot_ms - base_ms, 0.0)
metrics[f"{key}.ms"] = int(round(rep_ms))
metrics[f"{key}.records"] = records
metrics[f"{key}.wal_bytes"] = wal
# NANOseconds, integer: us_per_record rounded 5.5 and 5.3 to 6 and 5,
# which is too coarse for the one number iteration 3 exists to improve
metrics[f"{key}.ns_per_record"] = int(round(rep_ms * 1e6 / records))
res[legname] = (rep_ms, wal, records)
ok(f"{key}: {rep_ms:.0f} ms replaying {records} records "
f"({rep_ms*1000.0/records:.1f} us/record), WAL {wal} B")
ins_ms, ins_wal, _ = res["inserts"]
his_ms, his_wal, _ = res["history"]
# premise check: an update MUST cost a WAL record, or the two shapes are
# the same measurement and history_penalty_x means nothing
if his_wal < ins_wal * 1.5:
bad("replay: updates are not appending WAL records",
f"history WAL {his_wal} B vs inserts {ins_wal} B -- expected ~2x")
return
ok(f"replay: {REPLAY_N} updates doubled the log ({ins_wal} -> {his_wal} B) "
f"with the live row count unchanged")
penalty = his_ms / max(ins_ms, 1.0)
metrics["replay.history_penalty_x"] = round(penalty, 2)
ok(f"replay: identical dataset, {penalty:.2f}x the boot cost from history alone "
f"({ins_ms:.0f} -> {his_ms:.0f} ms) -- what a checkpoint would collapse")
def main():
# --check <results.json>: gate-only evaluation of a recorded run — the
# gate-bites smoke doctors a copy and this mode must FAIL on it
@ -528,6 +638,7 @@ def main():
growth(metrics)
ceiling(metrics)
randread(metrics)
replay(metrics)
os.makedirs(RESULTS_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S")
out = os.path.join(RESULTS_DIR, f"run-{stamp}{'-quick' if QUICK else ''}.json")