results: the M0 gates, measured

PLAN D7's six items, with the numbers and the command that reproduces each in
tests/e2e/results/m0-gates.txt. Unit tests green in both optimize modes, the
whole e2e matrix green, the spec scorecard byte-identical at 131/161/195, and
the large smoke run at the scale D7.3 asked for:

  21.47 GB collection (1,310,720 x 16 KiB)
  data file                 21.75 GB      (+1.3% over the documents)
  log after the load        2.5 MB        (checkpoints reclaim it)
  kill -9 then reopen       0.5 s         (0.5 s at 4 GB too -- flat)
  RSS after reopen          237 MB        (1.1% of the data)
  count after restart       1,310,720     last document byte-intact
  acked writes after kill   200/200

That is the milestone's claim, measured: an open costs the working set rather
than the size of the database. Before M0 the same measurement was 523 MB
resident for a 512 MB database, because recovering each document's `_id` meant
reading every document at open.

Two gates need reading rather than a tick, and m0-gates.txt says so where a
reader would otherwise take a tick for granted.

The churn gate settles at 1.65x live data (delete-heavy) to 2.47x
(update-heavy), flat, above the ~1.3x amendment A2 hoped for. Rebuild-only
reclamation cannot reach that: it needs a whole second copy of the live data
before the first can be freed. The gate existed to decide whether doc-level
free lists are needed after M0, and that is the answer.

Benchmark parity holds for every read and latency row inside the run-to-run
spread, and bulk insert regresses 24% (732 -> 555 MB/s), reproducibly across
three runs. Risk 1 as written: document bytes now reach the disk uncompressed
on top of the LZ4 log. createIndex improves 62% from the same change.

Three measurement bugs fixed while running the gates, because each would have
put a false number in the README:

  - `compare-run.sh` measured "db on disk" as `du` of the log alone against
    `du` of mongod's whole dbpath. It reported 20 MB for a 1 GB collection --
    the documents had moved to <db>.data. Honest figure, measured: 914 MB of
    allocated blocks against mongod's compressed 85 MB.
  - `big.js` counted "compaction events" as "the log shrank", which is a
    *checkpoint* now. It claimed 12 compaction rewrites during a pure insert
    load, which has no garbage to compact.
  - `big.js` labelled peak RSS "in-memory engine: docs live in RAM" and its
    summary said the collection was held "fully in RAM". Both were true of the
    engine this milestone replaced.

README: the storage section described an all-in-RAM engine; the comparison
table mixed one old run's body with three new rows; and `findOne({_id})` was
documented as a full scan for integer ids, which the ordered `_id_` index made
false (2 ms against 55 s for a scan of the same 21.5 GB collection). The table
is now best-of-three for both servers, with the measured variance stated, since
two runs of the same binary moved the sub-10 ms rows by 27-51%.
This commit is contained in:
2026-08-03 23:09:43 +03:00
parent 4b70ce6da9
commit 504179acd1
9 changed files with 505 additions and 87 deletions

80
PLAN.md
View File

@@ -532,13 +532,79 @@ discipline lives, and `ls`/`du` stay honest for D6.6's backup story.
| 16 | drop the docs hashmap | original step 7 |
| 17 | gates | D7's six items; churn gate per amended D6.2 |
M0 is done when D7's six items pass. Two harness fixes are prerequisites for
the gate rather than the work: `bench-run.sh` copies its report over
`bench-latest.txt` unconditionally, including after a run that only warned,
so the baseline being defended can be clobbered; and the four B+tree dev
harnesses (`spill.zig`, `spill2.zig`, `stress.zig`, `fuzz_split.zig`) are in
no build step, so `zig build test` will not notice an API break in the one
place that fuzzes splits and >1 KB keys.
All seventeen commits are landed. Both harness fixes that were prerequisites
for the gate rather than the work are in: `bench-run.sh` no longer copies its
report over `bench-latest.txt` after a degraded run (it used to, so the
baseline being defended could be clobbered), and the four B+tree dev harnesses
(`spill.zig`, `spill2.zig`, `stress.zig`, `fuzz_split.zig`) now have a
`zig build fuzz` step — they were in no build step, so `zig build test` could
not notice an API break in the one place that fuzzes splits and >1 KB keys.
### The gate result
Measured numbers, and the command that reproduces each, are in
`tests/e2e/results/m0-gates.txt`. In short:
| D7 | gate | outcome |
|---|---|---|
| 1 | unit tests RF + RS | pass, 122/122 both |
| 2 | e2e matrix unchanged | pass, every suite |
| 3 | 20-30 GB smoke, RSS ≈ working set, fast reopen | pass at 21.5 GB |
| 4 | churn gate | measured, bounded, above the hoped-for 1.3x |
| 5 | benchmark parity | pass except bulk insert, -24% |
| 6 | spec runner + scorecard | pass, scorecard unchanged |
Two of those need reading rather than a tick.
**D7.4** hoped for ~1.3x of live data and settles at 1.65x (delete-heavy) to
2.47x (update-heavy), flat in both cases. Before the gate's own findings were
fixed it was 4.1x and *climbing linearly* — nothing was being reclaimed at
all. The gate's stated purpose (amendment A2) was to decide whether doc-level
free lists are needed after M0, and the answer is yes, in M1: a rebuild needs
a whole second copy of the live data before the first can be freed, so
rebuild-only reclamation cannot reach 1.3x however it is tuned. What M0 owed
was a bound, and there is one.
**D7.5** says "no phase8 row regresses". Bulk insert throughput regresses
24%, reproducibly (732 → 555 MB/s). That is risk 1 as written: document bytes
now reach the disk uncompressed in the data file on top of the LZ4 log, and
that writeback bandwidth is new. Every other row is inside this machine's
run-to-run spread, which two consecutive runs of the same binary showed to be
27-51% on the sub-10 ms rows — so the gate is met for the read and latency
rows and not for bulk load. `createIndex` improves 62%, also reproducibly,
from the same change.
### What the gates found
Five bugs, none of which any unit test or e2e suite had reached, and four of
which need either a long-running workload or a second concurrent writer to
appear at all:
1. The compaction trigger had been dead since commit 14 — it gated on
`log.data_bytes`, and truncating the log at every checkpoint zeroes that.
2. `page_mut_cow` asked `p >= stable_pages`, which is false for a recycled
page, so every write to one copied and freed it again.
3. First fit let copy-on-write's one-page requests shave the extents the doc
slab needs, so the free list drained every generation.
4. A rebuild's freed space took two unrelated checkpoints to become reusable,
so the next rebuild grew the file instead of reusing it.
5. `reserve_pages`' promise was a single counter on the pager, and two upserts
on different collections release it independently — so the first to finish
revoked the second's promise mid-write. This aborted the server at four
concurrent clients on the first concurrent benchmark since the data file
landed. Risk 3, whose mitigation (private pre-allocated runs) was listed in
this document and never built.
The lesson worth carrying into M1 is the shape of 1-4: every one is a
*reclamation* bug, invisible to any test that does not run long enough to
reach a steady state. The unit suite proved each mechanism works once. Only
the churn gate showed that none of them worked twice.
A compatibility gap also surfaced, out of M0's scope and left alone:
`update.apply` (`src/update.zig:18`) rejects any update document whose first
key is not `$`, so `replaceOne`, `findOneAndReplace` and `bulkWrite`'s
`replaceOne` all fail with "bad update". It is a CRUD feature rather than
storage, and it is already inside what the 161 spec failures cover.
---