The single engine-wide reader/writer lock is replaced by a lock hierarchy, so writes to different collections no longer serialize on one mutex: - Collections are heap-allocated, so their addresses are stable while a command holds a collection lock (the maps only store pointers). - A catalog rwlock guards the database/collection maps: shared for every command (so a concurrent DDL cannot mutate the maps underneath it), exclusive for create/drop/dropDatabase. Each collection has its own rwlock; the ordering is always catalog -> collection -> log lock, never two collection locks at once (TTL sweep and compaction take collections one at a time). - Command dispatch acquires the catalog + target collection locks for the handler's duration, resolving the collection (creating it for writes) under the catalog lock; create/drop upgrade to the exclusive catalog lock. - Appends never fsync. Each write command's epilogue releases the collection lock, then commits once (seal + fsync) with a leader/follower group commit: the leader waits for writers mid-append (a pending counter) so its seal covers them, and followers whose records the seal covered skip their own fsync. Every acknowledged write is fsynced before its reply (crash pair verified); an unacknowledged write may vanish and a reader may observe a write before its fsync — ordinary w:1 j:true semantics instead of 'the log describes >= memory'. - Compaction snapshots collections without the log lock (so a concurrent writer holding one can always finish its append) and retries when a writer appended mid-snapshot (detected via the record seq), then swaps under the log lock — no deadlock. The compaction trigger moved to the command epilogue and the TTL monitor. - Engine.dup_index moved to the collection (per-command error paths). Also lands two B-tree edge-case fixes driven by tests that were in flight: a churned leaf full of dead bytes no longer splits with an empty right half (the leaf is repacked before splitting, and an emptied node's page is fully free again), and a slot-count split with all large records on one side shifts records between the halves until the new record fits. Plus a randomised fuzz test over key sizes (src/fuzz_split.zig) and the two regression tests. Measured (tests/e2e/results/phase6.txt): no regression on the single-connection benchmark; concurrent durable-insert throughput ~5.1k -> 12.5k docs/s from 1 -> 8 clients, ~14.8k at 32. Verified: unit suite in all three modes, all e2e suites, the kill -9 crash pair.
End-to-end tests with the official MongoDB Node.js driver
These exercise mongo-lite 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/mongo-lite --port 27020 --db /tmp/ml-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
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/mongo-lite 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 ReleaseFast —
zig builddefaults 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 2gthe 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 mongo-lite
on :27019, runs the same driver workload against each (durable writes:
mongo-lite 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).