Files
MultiforaDB/tests/e2e
A.Shakhmatov a748a3d08c db/pager/tests: cleanup pass over the free list
No behaviour is meant to change and the gate confirms it: 1.94x / 679.2 MB
reclaimed / 9 rebuilds and 2.46x / 934.0 MB / 13 on the two 16 KiB lines,
0.0 MB on the 200-byte line, all identical to the numbers recorded for them.

Deduplication. `pages_for` was written in db.zig and again in pager.zig and
twice more inline; there is now one, public, and the two pre-existing copies
call it. `pages_per_map_align` replaces three hand-rolled `map_align /
page_size`. `SlabRun.window_first` was a stored field that could never legally
disagree with `first` and was maintained by hand at two sites -- now a method.
`keep_piece` re-derived `SlabRun.window_count` character for character; it
calls it. `insert_run` scanned linearly for a position `run_of` binary-searches
for, which made loading a fragmented catalog quadratic; both now go through one
`run_lower_bound`. Freeing a run's window map was written four times; one
helper. The 20% rebuild share was stated in `note_compact` and again in
`wants_rebuild`, with a comment arguing at length that they must be the same
number -- `worth_rewriting` makes that structural.

Efficiency. The identity assert in `reclaim_windows` called `dead_located()`,
an O(every window) walk, and `assert_msg` is live in ReleaseFast -- so it
doubled the scan the reclamation was about to make (2.75 MB streamed twice per
reclaiming checkpoint at the 21 GB the gate targets). `dead_located` is now a
maintained counter, the check is O(1) in every build, and the scan cross-checks
it while it is there. `SlabRun.full` lets a run with nothing to give be copied
without its counters being read at all, so the common case is O(runs) rather
than O(windows).

The pager's two allocation policies were hand-copying the claim step, and the
copy had already lost two of the three preconditions -- `alloc_slab_run` never
checked `pages <= reserved_pages`. Both now go through `claim_locked`.

`reclaimed_bytes` moves from Collection to Engine, beside `compactions`, which
is how it is read and the only place it can be honest: a life-of-the-process
total must not lose a dropped collection's share. Both join `Counters`, so
`slab_stats` stops opening `counter_lock` by hand.

The ownership assertion in `write_catalog` was gated on `is_test or Debug`, a
predicate nothing else in the codebase uses, which left the one silent failure
this design can produce unchecked in ReleaseSafe. It is now `!= ReleaseFast`,
the line `protect_stable` already draws. Measured: no change to the suite's
runtime.

Altitude. `note_checkpoint` was called from exactly one place, the tail of
`upsert` -- so a delete armed no checkpoint by any route, which is why
reclamation only ever ran when the *rebuild* trigger fired and the rebuild then
reset the window map it would have used. `remove` and the TTL sweep arm one
now, next to the `note_compact` calls that were added for the same omission a
milestone ago. `compact`'s leading checkpoint stays, demoted in its comment
from the mechanism to the local ordering it actually guarantees.

serverStatus reports `allocTailBytes`/`freeReadyBytes` instead of page counts,
so the harness stops hard-coding 4096 -- the kind of constant this milestone
was blindsided by once already.

tests/e2e/churn.js: `deleteMany({_id: {$in: [5000 ids]}})` exceeded
`index.max_combos`, so the planner refused the index and every delete became a
full collection scan re-filtering each document against 5000 members. That was
the entire runtime of the harness. One delete spec per id instead: the 40k x
16 KiB gate goes 57 s -> 6 s, and the 150k x 200 B line 483 s -> 3 s, with
identical output. Also: the per-round `countDocuments` is gone (the harness
knows the count), and the server log is a bounded ring rather than a rope that
grows with everything the server ever said.

Reverted from the review: reusing one MongoClient across the startup poll. A
client whose first connect fails tears its topology down and every later
command on it fails identically, so it turns "not up yet" into "never comes
up" -- it broke the first run. The reason is now a comment.

187/187 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, e2e 49, e2e2
concurrent 2 and the crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86,
crash-fuzz 60 cycles.
2026-08-09 19:08:15 +03:00
..
2026-08-03 23:09:43 +03:00
2026-08-03 23:09:43 +03:00
2026-08-03 12:35:01 +03:00
2026-08-03 12:35:01 +03:00
2026-08-03 12:35:01 +03:00
2026-08-03 20:41:08 +03:00
2026-08-03 12:35:01 +03:00

End-to-end tests with the official MongoDB Node.js driver

These exercise MultiforaDB from a real driver over TCP: full CRUD, query operators, aggregation, error codes, concurrent clients, crash recovery, and the whole lifecycle including server restarts.

Setup

cd tests/e2e
npm init -y >/dev/null
npm install mongodb

Run

Most suites expect a server running on port 27020:

zig build
zig-out/bin/multiforadb --port 27020 --db /tmp/mfdb-e2e.log --ttl-sweep-secs 1 &

node tests/e2e/e2e.js          # CRUD + operators + aggregate + errors (29 checks)
node tests/e2e/e2e2.js concurrent   # 8 clients: 4 writers + 4 readers (2 checks)
node tests/e2e/e2e2.js crash-a      # write 50 docs, then kill -9 the server
node tests/e2e/e2e2.js crash-b      # restart and verify all 50 survived
node tests/e2e/e2e3.js         # secondary indexes: unique/sparse/compound (16 checks)
node tests/e2e/e2e4.js         # TTL indexes: expiry + rejected specs (15 checks)

e2e4.js needs the server started with --ttl-sweep-secs 1 (the default is 60 seconds); the other suites do not care about the flag.

e2e6.js is the full-lifecycle suite and is self-contained: it spawns its own server on port 27220 with a fresh log, runs the whole feature surface, restarts the server twice (graceful SIGTERM, then kill -9 mid-write) and verifies everything survived:

node tests/e2e/e2e6.js              # 73 checks, ~15 s, needs no running server
E2E6_PORT=27300 node tests/e2e/e2e6.js   # different port if 27220 is taken

e2e7.js is the cursor suite, self-contained for a different reason: cursor behaviour is only observable with non-default flags. It spawns three servers in turn -- default flags for batching/streaming/aggregate, then --cursor-timeout-ms 800 --cursor-sweep-secs 1 --max-open-cursors 4 for idle expiry and registry capacity, then a restart on the same database to confirm a cursor does not survive one. Most of it uses raw runCommand, because the driver hides cursor.id and that is the thing under test:

node tests/e2e/e2e7.js              # 86 checks, needs no running server
E2E7_PORT=27310 node tests/e2e/e2e7.js   # different port if 27230 is taken

Rebuild with zig build after any change under src/ before restarting the server: zig build test compiles the test binary only and leaves zig-out/bin/multiforadb stale, so the suites keep running against the old rules and report failures that the source no longer explains.

e2e2.js concurrent is safe to repeat against a running server (it drops its collection first); crash-a/crash-b are two halves of one scenario.

Multi-GB collections: big.js

big.js is a load harness, not a pass/fail suite: it spawns a server, bulk loads up to ~5 GB, and reports insert throughput, the compaction behavior, server RSS, per-operation latencies, reopen (replay) time, and kill -9 durability.

node tests/e2e/big.js --quick                      # 268 MB smoke run
node tests/e2e/big.js --size 5g --doc-size 128k --oid --batch 200 \
                       --compact-threshold 2g      # ~5 GB, 40k docs

Options: --size/--doc-size/--batch (k/m/g suffixes), --oid (ObjectId _ids — see below), --index <field> (secondary index before loading), --compact-threshold <bytes> (passed to the server), --port, --keep (keep the db file).

Measured behavior (all documented in the top-level README):

  • Build in ReleaseFastzig build defaults to it; a Debug server is 10-200x slower on every path.
  • Insert throughput collapses under the default 16 MiB compaction threshold: every ~16 MB of writes rewrites the whole log with one fsync per record (O(n²) total). With --compact-threshold 2g the rate stays flat (hundreds of MB/s at 128 KB docs in ReleaseFast). Raise the threshold for bulk loads.
  • findOne({_id}) is O(1) only for ObjectId _ids. Integer _ids are serialization-ambiguous (int32/int64/double compare equal but hash differently), so the docs-map fast path is skipped and every lookup is a full scan. Use the driver's default ObjectId ids on big collections.
  • The engine holds everything in RAM: ~1-1.2x the data size at 128 KB docs (more at 16 KB docs, where per-document arena overhead dominates). A 5 GB collection needs roughly 6-7 GB of RAM.
  • Reopen of a 5 GB log replays in ~10 s (ReleaseFast); every committed write survives kill -9.

Comparing against real MongoDB: compare.js + compare-run.sh

bash tests/e2e/compare-run.sh [size] [doc-size]   # e.g. 1g 16k

Starts mongod (brew install mongodb-community) on :27018 and MultiforaDB on :27019, runs the same driver workload against each (durable writes: MultiforaDB fsyncs per command, mongod runs with j: true), measures kill -9 reopen for both, and prints a side-by-side table. compare.js alone runs one side (see its --help-style header comment).

Iteration-to-iteration comparison: bench-run.sh + concurrent.js

bash tests/e2e/bench-run.sh [size] [doc-size] ["clients..."]   # e.g. 1g 16k "1 4 8 16 32"

Runs the main suite (compare-run.sh) plus a concurrent durable-write comparison (concurrent.js, N clients each doing sequential insertOne with {w:1, j:true} — the group-commit path under real contention), then writes a machine-readable, versioned report to tests/e2e/results/bench-<timestamp>.txt and prints a diff of the MultiforaDB numbers against the previous run (results/bench-latest.txt). The report has [main] / [concurrency] / [meta] sections with name<TAB>value rows; bench-run.sh 1g 16k reproduces the phase8 gate (see results/phase8.txt).