b20ae92cbf03048600d605301eda08b8277ab1b2
58 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| b20ae92cbf |
db: drop the docs hashmap; the _id_ index is the lookup
The last structure holding the engine to RAM. At the target scale it cost 64-100 bytes per document -- 10+ GB at 100M documents -- and PLAN D4 rules it out for exactly that reason. `_id_` was already an ordered B+tree over the canonical `bson.encode_key`, and since the leaf payload became a slab offset it has held everything the map did. So the internal key changes from `serialize_value` to `encode_key` throughout, `lookup_exact` replaces `docs.get`, and the tree's ordered walk replaces the map's hash-order iteration in `create_index`, `rebuild_index`, the rebuild and the TTL sweep -- which reads the slab sequentially where the map read it scattered. `Collection.doc_count` remains, because the compaction trigger wants a count the tree cannot give in O(1). This is what unblocked the milestone's central claim, and the mechanism is worth naming. Opening from a checkpoint had to rebuild the map, and rebuilding it meant reading *every document* to recover its `_id` -- which faulted the entire database in and made "RSS = working set" impossible no matter what else was true. Deleting the map deleted that scan. Measured, 512 MB of documents in 16 KB records, reopening from a checkpoint: RSS after reopen 523 MB -> 50 MB after 4 point lookups 523 MB -> 51 MB The remaining 50 MB is the working set: index pages plus the un-checkpointed log tail being replayed. A checkpoint immediately before shutdown would shrink it further; the point is that it tracks what is touched rather than what is stored. -- Replay now maintains `_id_` as it goes, always, not just after a checkpoint. It is no longer an optimisation: the tree is the only way the next record can find the document it supersedes. Secondaries still wait for the bulk build. And PLAN amendment A4's migration hazard is handled where it actually bites. A database written before `_id_` was canonical could hold two documents whose `_id`s compare equal -- int32 1 and int64 1 -- and replaying it now keeps only the later one. That is MongoDB's semantics and a one-way migration, so replay compares the superseded document's `_id` bytes with the incoming record's and says so out loud when they differ, naming the namespace. |
|||
| 148e03ac9f |
db: compaction becomes a data-file rebuild
`compact` used to re-emit every live document into a fresh log and rename it over the old one. That is the wrong shape twice over now: the log is not where the data lives, and a re-emitted record carries a sequence a later watermark can cover, which would make the next open skip it (PLAN section 4). The log re-emission is deleted; the checkpoint at the end reclaims the log instead. What it reclaims is what a checkpoint cannot. A checkpoint publishes the structures where they already are, and it cannot move a document, because every index leaf holds that document's physical offset. So reclaiming a replaced document's bytes means rewriting the documents *and* repacking every index against the new offsets, together -- which is the whole of `rebuild_collection`. Documents are copied in _id order, so the new slab reads sequentially afterwards. Old extents and old node pages go to the free list rather than being reused immediately, so a crash mid-rebuild simply loses the rebuild: the previous watermark still describes the previous layout, intact. Adds `Collection.slab_used`, because `slab_tail` cannot answer "how many bytes are in use" -- it is an absolute file offset and jumps forward with each new extent. That is also the number the rebuild trigger wants. -- The test is the part worth reading. My first version asserted that every document was still findable and had the replaced contents, and it was nearly useless: two mutations -- not repacking the indexes at all, and not republishing the docs-map offsets -- both left it green. Freed extents go on the free list rather than being overwritten, so a stale offset still reads a perfectly plausible document. What actually distinguishes a repacked index from a stale one is *where* the offset points: after a rebuild every live offset must fall inside an extent the collection currently owns. Asserting that, plus that the index and the map agree, turns all three mutations red -- including repacking `_id_` but forgetting the secondaries. |
|||
| 138b7f706f |
db/storage: reclaim the log once a checkpoint covers it
The point of a lagging checkpoint: a record whose effect the data file already holds is redundant, so the log can go back to just its header. Without this the log only grows and every open pays for every write ever made. Ordering, which is the whole safety argument: publish the watermark, *then* truncate. The other way round, a crash between them leaves the records gone from the log and absent from any image. A failed truncation is a warning rather than an error -- it costs space and replay time, and loses nothing, so it must not fail a checkpoint that already succeeded. Also wires checkpointing up, which nothing did before. `note_checkpoint` arms it when the log passes a threshold, and the write epilogue and the TTL monitor both claim it -- outside any collection lock, for the same reason compaction runs there: it takes the log lock. The threshold is separate from the compaction one on purpose: compaction is about the garbage share of the data, a checkpoint is about how much replay an open would otherwise do. -- Two things the tests taught me. The first version measured the log before the checkpoint and found 16 bytes -- just the header. Appends buffer in the log's open block and only a commit seals and writes it, so there was nothing on disk to shrink. The test commits first now, and says why. And the "no valid watermark" warning fired for every young database, which is its normal state before the first checkpoint. It now distinguishes a watermark that was *written and cannot be read* from one that was never written -- warning about the ordinary case is how people learn to ignore the warning that matters. Mutation-checked, red: skipping the truncation. Not covered, and the test says so: moving the truncation before the publish, whose failure mode is a crash landing between the two. That needs process-level crash injection, which an in-process test cannot express. |
|||
| 58e645b969 |
db: checkpoint the engine, and open from it
`Engine.checkpoint()` publishes the current state: commit first, then snapshot the catalog under the catalog lock, then validate the snapshot against an unchanged `seq` under the log lock before publishing -- the same bounded-retry shape compaction has always used. The crash-recovery invariant (PLAN D6) reduces to that ordering, and it is asserted: `snapshot_seq <= committed_seq`. The catalog holds what the pages cannot say for themselves: db and collection names, slab extents and tails, and for each index its spec, tree position, overflow extents and id->page table. Written wholesale into fresh pages each time, never mutated in place, so the previous copy stays valid under the previous watermark until the new one switches over -- untearable by construction, which is why there is no incremental update path. Every read is bounds-checked, because the bytes come off disk and a scrambled catalog must produce an error the caller can fall back from. `Log.replay` takes a `from_seq` and skips below it before the BSON parse. The walk still visits every block, because that is what leaves `end_pos` correct for the next append; making opens *fast* is the job of truncating the log, next. A failed catalog load warns, discards what it loaded, and replays the log in full. The log is untouched at this commit, so that fallback is real rather than aspirational -- which is the reason to land this before truncation. -- The docs hashmap is deliberately *not* in the catalog. It is still the authoritative _id lookup, but putting it there means writing a format the commit that drops it would only delete again; it is rebuilt by walking the `_id_` tree, which the data file already holds. -- One real bug, and it is the interesting part. Replay does not maintain index entries -- it puts documents in place and lets `build_all_indexes` bulk-pack afterwards, which is O(n log n) once rather than per record. After a checkpoint that is wrong: the indexes arrive already populated, `rebuild_index` skips a non-empty one by design, and every record replayed on top was invisible to every index. The symptom was a document present in the collection and absent from `_id_` -- which, once the hashmap goes, means simply absent. Replay now maintains entries when it opened from a checkpoint, and keeps the bulk path for a full one. `Engine.seq` is restored, which it never was: it restarted at 0 on every open. The mutation for it is *not* covered and the test says so rather than implying otherwise -- the sequence is seeded from the watermark, so it only drifts by the records replayed on top, and the catalog carries those same records in every sequence a unit test can reach. Observing the drift needs a crash between a duplicate-sequence append and the checkpoint that would have captured it. The line stays because a log without monotonic sequences has no total order. |
|||
| d7f7ebb994 |
pager: copy-on-write above the stable mark
The invariant everything else in the crash story rests on (PLAN amendment A1): no page belonging to the last published image is ever stored into, so recovery is `image + replay(seq > watermark)` and the image's bytes are exactly what the watermark described. `page_mut_cow` takes a *pointer to the owner's page number*. That is the load- bearing detail: copy-on-write relocates the page, so the owner has to be told, and a second reference would still aim at the abandoned copy. For the B+tree the owner is the id->page table slot -- which is precisely why node ids are not page numbers. Inert until a checkpoint publishes something, since the stable mark starts at zero. Append-only consumers keep writing in place, except that a checkpoint landing mid-extent freezes the page their tail points into, so both slabs now start a fresh extent rather than writing inside the image. Waste is bounded by one extent per collection per checkpoint. The free list is wired into allocation, which it was not before: copy-on-write abandons every page it touches in every generation, so without reuse the file grows by `generations x touched_set` without bound. That is the difference between a free list being defense-in-depth and being a prerequisite (A2). -- Three things I got wrong on the way, all worth recording. I added a `p >= stable_pages` assert to `page_mut` and had to take it back out. A page recycled off the free list *is* below the mark and *is* legitimately writable -- freed two generations ago, referenced by no live image -- so the page number alone cannot tell a violation from a reuse. The invariant is enforced the two ways A1 actually describes: structurally through `page_mut_cow`, and mechanically through mprotect. The comment says so, since the assert looks like an obvious thing to add. The watermark slots needed a narrow exception, because overwriting the inactive slot is the publication mechanism rather than a violation. It is a separate non-public accessor that asserts its argument is a slot, so it cannot become a general escape hatch. And the mprotect belt: `std.posix.mprotect` does not exist in Zig 0.16, so it is a libc call. It compiled only in ReleaseFast, where the branch is comptime- eliminated -- ReleaseSafe caught that immediately, which is the argument for running both. What the belt's test asserts is that the protection is really applied, not that a violating write faults. A SIGSEGV cannot be caught in-process, and the fault is the OS's behaviour rather than this code's; an mprotect that failed silently would leave a belt that looks present and does nothing, which is the failure worth guarding here. Stated in the test rather than implied. Mutation-checked, all red: COW returning without copying; copying without moving the slot; copying when already above the mark; never reusing a freed page. |
|||
| 2e7f72074f |
index: the node arena and overflow slab live in the data file
The last structures move onto the pager, so the whole engine's storage is now
one mapped file plus the WAL.
Node ids are deliberately *not* page numbers. PLAN amendment A1 explains why:
`Node.parent`, `next`, `prev` and an internal slot's `extra` are back-pointers
by id, so copy-on-write moving a page would force every node referring to it to
move as well -- COWing one leaf cascades through the leaf level, one internal
node through its whole subtree. An in-RAM id->page table makes the table slot
the single owner of a page number, so COW has exactly one pointer to fix. It
costs one dependent load per node access and 4 bytes per node, about 5.6 MB at
100M documents, against the 64-100 bytes *per document* this milestone removes.
The overflow slab becomes extents too, so `Slot.off` for a spilled record is an
absolute file offset -- the same change documents went through.
--
Two bugs, both found by measuring rather than by reading, and both worth
recording because the second one would have been invisible until the churn gate.
The reservation was a tail mark, and it cannot be: an upsert reserves tree pages
for every index *and* slab room for the document, all before one log append. The
second reserver overwrote the first one's promise and the first one's allocation
then asserted. Caught on a 512 MB load by the tripwire added in the
`reserve_for` commit, which is the entire reason that assert exists. It is a
count now, and the multi-consumer ordering is pinned by a test.
And a reservation was never released. It is scoped to one write -- taken before
the log append so the publish cannot fail -- but a tree reservation covers the
worst case of several splits while a typical insert causes none, so the promise
accumulated by a handful of pages per write and dragged the file up with it. The
data file was **1.89 GB for 512 MB of documents**; releasing the unclaimed
promise at the end of each write brings it to 551 MB, or 1.08x, which is the
extent slack and the node pages.
--
Measured on one harness, 512 MB / 16 KB docs, against the in-RAM engine this
replaces:
bulk insert throughput 742.6 MB/s -> 736.4 MB/s
insertOne (sequential) 0.20 ms -> 0.22 ms
createIndex({k: 1}) 26.8 ms -> 16.5 ms
countDocuments({}) 2.1 ms -> 1.1 ms
findOne({k: 500}) indexed 0.75 ms -> 0.56 ms
find({p: range}).count() 6.6 ms -> 4.6 ms
aggregate $group by k 5.8 ms -> 3.8 ms
updateMany({k: 7}, {$inc}) 1.2 ms -> 1.0 ms
Reads gain from one contiguous mapping; the two write rows are within noise of
flat. RSS is still unchanged and still cannot improve, for the reason given in
the previous commit: every open replays the whole log and rebuilds everything.
The dev harnesses each open their own data file now. `zig build fuzz` caught all
four of them, again.
|
|||
| 9dda943f26 |
db: documents live in the data file
Collection's slab of malloc'd 8 MiB segments becomes extents in the data file,
and a document's offset becomes an absolute file offset. That makes `doc_bytes`
base + off instead of a binary search over segment starts, and it removes the
hazard the segment list carried: the mapping's base never moves, so a slice into
it cannot be invalidated by growth. phase8 records a dangling-slab-pointer bug
of exactly the shape this deletes.
`slab_reserve` now runs before the log append and `slab_append` after it, and is
infallible. It had no reservation before because appending to an ArrayList could
only fail on OOM; a file-backed slab can also fail on growth, and failing after
the record is durable would report an error for a write the next open produces
anyway.
The data file is still recreated empty on every open and the log still replays in
full, so nothing durable depends on it yet and open/close semantics are
byte-for-byte what they were. The watermark that turns it into a checkpoint comes
next.
--
One bug, and it is worth reading because the milestone keeps producing this
shape. Collection holds a `*Pager`, and `Engine.open` builds an Engine on the
*stack* and returns it by value -- so every pointer taken during replay dangled
the moment it was moved. It surfaced as SIGBUS, then as a corrupt hashmap in the
*second* engine of an unrelated test: nothing resembling its cause. The pager is
heap-allocated now, as `Index` already was, for the same reason.
--
Measured on one harness, 512 MB / 16 KB docs, before and after:
bulk insert throughput 742.6 MB/s -> 746.7 MB/s
createIndex({k: 1}) 26.8 ms -> 16.2 ms
countDocuments({}) 2.1 ms -> 1.1 ms
findOne({k: 500}) indexed 0.75 ms -> 0.53 ms
find({p: range}).count() 6.6 ms -> 4.1 ms
aggregate $group by k 5.8 ms -> 3.7 ms
insertOne (sequential) 0.20 ms -> 0.20 ms
Every row equal or faster. The regression this commit was scheduled early to
catch -- document bytes now reaching disk uncompressed on top of the LZ4 log --
did not appear at this size; bulk insert is flat and the reads gain from one
contiguous mapping instead of separately allocated segments.
What did *not* improve, stated plainly because it is the milestone's headline
claim: RSS is unchanged, 552 MB against 563 MB for 512 MB of data. It cannot
improve yet. Every open still replays the whole log and rewrites the whole slab,
so every page is touched and resident regardless of where it lives. "RSS =
working set" only becomes measurable once an open loads a checkpoint instead of
rebuilding, and the honest test for it is the multi-GB reopen in big.js, not this
microbenchmark.
|
|||
| c3e7368477 |
pager: watermark double buffer, page free list, and the ordering that matters
Still engine-unused; this completes the data file's own machinery so the structures can move onto it next. The watermark is what a checkpoint publishes: the log sequence the image covers, the allocation extent, and where the catalog and free list live. Two slots, written by generation parity, so a torn write can never leave zero valid slots -- writing a new generation over the only copy of the old one could. The newer slot whose hash validates wins, and validation happens *before* the generation comparison, or a torn slot wins whenever its garbage generation happens to be larger. A slot that validates is trusted, so it is checked for sense as well as integrity: a rehashed slot with an impossible alloc_tail would otherwise be believed and would describe a file that does not exist. Both slots unreadable opens anyway, with a loud warning and no checkpoint, which means a full replay. Ground rule 4: refusing to start costs more than the warning does. The free list releases pages two generations after they are freed. That is not caution -- it is what keeps generation N-1 a usable image, since its pages stay allocated while N is current, so a torn watermark or a bad catalog can fall back instead of discarding the database. -- Two things worth reading, because both were wrong first. Publish captured alloc_tail *before* writing the free list, so the pages the free list occupies fell outside the published image and the next open would have handed them out again while the watermark still pointed at them. Found by the first test written against it. And the ordering invariant -- every page a watermark describes is durable before the watermark that describes it -- was untestable, which is worse than untested. A kill -9 does not lose page-cache writes, so deleting the sync leaves every test green while making a real power loss unrecoverable. So the pager now records, in test builds only, which pages have been written since the last sync, and a test discards exactly those from the file before reopening. That simulates the one failure a process kill cannot produce. `track_dirty` is `builtin.is_test`, so `page_mut` carries no branch in a real build. Mutation-checked, and the precise result is in the comments because the obvious mutation is not a violation: publish syncs twice before the watermark, so removing either call alone is harmless and correctly stays green. Removing both, or deferring them past the watermark, goes red. Also red: always writing slot A; accepting an impossible alloc_tail; releasing freed pages a generation early. Noted as belt-and-braces rather than claimed as covered: the generation-zero check, which the hash already rejects. |
|||
| 04d56f5b66 |
pager: the data file, its page allocator and the mmap over a fixed reservation
New src/pager.zig, engine-unused at this commit: the structures move onto it in the commits that follow, and landing it alone keeps that change reviewable. The file is an array of 4 KiB pages with a tail-bump extent allocator. PLAN D6.1 asked for a region table; it cannot be one, because there is a node arena per index and a document slab per collection, so the region count is dynamic and unbounded and N contiguous regions cannot all grow at the tail. One page array means exactly one growth path, so the write-then-extend discipline lives in exactly one place, and `ls`/`du` stay honest for the backup story. The mapping is a PROT_NONE anonymous NORESERVE reservation that new file-backed suffixes are mmap'd into with MAP_FIXED. That is one VMA and zero committed pages, and it means **the base never moves for the life of the process**, so a pointer handed out before a growth is still valid after it. The ArrayList-backed arena this replaces could not promise that -- the promoted-key scratch buffer in index.zig exists solely to work around it, and phase8 records a dangling-slab-pointer bug of exactly that shape. Growth is `setLength` and *then* `mmap`, never the reverse: a store into a mapped page past end-of-file raises SIGBUS, which no error path can catch. The accessors assert against `mapped_pages`, so a violation is a panic with a message instead of a signal. `page_mut` is deliberately the only way to obtain a writable page. Copy-on-write hooks in there (commit 12), and funnelling every write through one function is what makes that a change of one body rather than of every caller. `sync` is msync + fsync and only the checkpoint calls it. Between checkpoints dirty pages may sit in the page cache indefinitely, because recovery is `image + replay(seq > watermark)` and the image's pages are never written *differently* -- which is what keeps the write path at exactly one fsync, the WAL's (PLAN amendment A1). This is the one place in src/ that reaches for std.posix, against the house style, and the module comment says why: std.Io.File.MemoryMap prefaults by default, exposes no NORESERVE/FIXED/address hint so it cannot express a reservation, and its setLength is mremap on Linux and unsupported on darwin. Mutation-checked, all four red: remapping the prefix at a kernel-chosen address; dropping the uuid comparison (which is what stops one database's log being replayed onto another's checkpoint); dropping the header hash comparison; and moving setLength after mmap. An empty file is treated as absent rather than as corruption, so a create that died before its header landed still opens. |
|||
| 9390021b1e |
index/commands: stream whole-index scans; add a reverse leaf iterator
A whole-index read used to materialize every candidate before the caller saw
the first one. At the tens-of-GB target that is a list of every offset in the
collection -- ~160 MB for a countDocuments({}) over 20 million documents --
which defeats the point of moving storage to disk. Cursors are M1, but
*streaming a scan* has to exist now.
`Candidates` is the one loop candidates arrive through, whatever produced them:
a plan's materialized lookups, or the index read end to end. That keeps this
file's governing invariant -- an index only generates candidates, the full
filter is re-applied to every one -- in a single place. A narrowed plan still
materializes, because its multikey/$in dedupe genuinely needs the whole set and
is bounded by selectivity.
`RevIter` walks `Node.prev`, which has always been maintained and which nothing
had ever read: a descending scan materialized the whole index and reversed the
list. `find({}).sort({_id:-1}).limit(20)` becomes O(20).
The unfiltered fallback now walks the _id_ index instead of the docs map. That
is ordered rather than hash-ordered, and it does not depend on a structure that
is about to be deleted.
`Plan.full_scan()` refuses multikey indexes, since one document contributes
several entries there and a stream cannot dedupe what `search` did. The check is
currently redundant -- the planner refuses to order a multikey index anyway --
and is kept because the two guards protect different things. Stated precisely
in both places after checking: the commands.zig test reddens only when *both*
guards are removed, which is what that test actually pins.
--
This also broke e2e6's compaction check, and the fix there is the more
interesting half.
The check required peak/final > 1.4 and got 1.28. The final size was identical
to the byte (2,398,065 vs 2,398,064) -- compaction reclaimed exactly as before
-- and only the peak moved. Isolated to one variable: changing just the order
updateMany({}) walks its matches moves peak/final between 1.65 and 1.28, because
compaction can also fire from the once-per-second TTL monitor and whether one
lands inside the batch shifts the peak a long way while leaving the outcome
unchanged. The threshold was measuring the schedule.
Replaced with `peak > final`, which measures the shape instead: an append-only
log grows monotonically, so its maximum *is* its final size, and a file that
was ever larger than it ended can only have been rewritten.
Worth recording why the obvious alternative does not work. An absolute size
bound cannot distinguish a working compactor here: the payload is one repeated
character, so ~48 MB of records LZ4-compress to ~3 MB whether or not anything is
reclaimed -- with compaction disabled entirely the file still ends at 3.1 MB. I
first wrote the comment claiming that bound was the strong one, then measured it
and found the opposite; `peak > final` is what goes red.
|
|||
| 491a4d0a6a |
index: a leaf record's payload becomes the document's slab offset
PLAN amendment A3. The B+tree leaf had nowhere to put a document's slab offset -- `Slot.extra` is the payload length for a leaf and the child node id for an internal separator -- which is what blocks the `_id_` tree from becoming the primary lookup once the docs hashmap goes away. A leaf record is now `key ++ offset_le`, so `extra` is always 8 and every byte-accounting site (fits, record_cost, slot_cost, balanced_cut, repack_keep_prefix) is untouched. Records get *smaller*: an ObjectId `_id_` record goes from 26 bytes to 21. `Entry.id` is deleted rather than re-owned. Every entry one document contributes shares one document, so which document it is belongs on the call that commits the entries -- which also makes it impossible to confuse the offset a replace is removing with the one it is inserting. The old field aliased the docs map's key and was only safe because removal happened at the one chokepoint where a document dies; that constraint is gone. Done for secondary indexes too, not just `_id_`. That deletes the per-candidate `coll.docs.get(id)` in scan_sorted outright rather than replacing it with an `_id_` descent, and it is free on the write path because a replace already removes and reinserts every entry in every index. Consequences worth knowing: - lookup_eq/lookup_range/Plan.search yield u64. Those are values, immune to the tree mutation that invalidated the id slices they used to hand back -- which is why ttl_sweep_coll can drop the dupe-and-free dance it needed to survive `remove` freeing the key its entries pointed at. - One safety net is gone. A stale entry used to be swallowed by `docs.get(id) orelse continue`; now it resolves to superseded-but-parseable bytes the re-applied filter might accept. That trades an invisible under-approximation for a visible wrong answer, which is the better failure to have, but it is a trade. - A checkpoint may never renumber slab offsets (already recorded in PLAN §4): every index leaf now holds a physical one. `zig build fuzz` earned its keep immediately -- it caught the API break in all four B+tree harnesses, which `zig build test` cannot see. Benchmarks A/B'd at 256m on one harness, before and after: all rows flat. updateMany and deleteOne+insertOne first looked 10-13% slower, which three repeat runs showed to be single-sample noise (0.70/0.71/0.70 against 0.70). |
|||
| 90de7820da |
tests/spec: MongoDB spec-test runner and the M0 scorecard
PLAN D2 makes the official specification suites the gate for command semantics; D7.6 asks for the harness to exist at M0 with a recorded baseline. This is that harness, pinned on both sides -- mongodb/specifications @ 615e0f9 and mongodb@7.5.0 -- because a scorecard is only comparable across milestones if a delta cannot be an upstream test change. It implements the unified format's Evaluating Matches algorithm as written, including the two rules that decide whether a pass is earned: extra keys are tolerated only in a root document, and numeric types compare flexibly. Anything unimplemented is a SKIP with a reason, never a pass, and the one assertion class not yet checked -- expectEvents, i.e. command monitoring -- is disclosed at the top of the scorecard so `pass` reads as an upper bound. First honest run: 131 pass, 161 fail, 195 skip over 175 files, zero timeouts. Getting there took four attempts, and the failures are documented in the README because each would have shipped a scorecard claiming a compatibility gap that did not exist. Two were genuine leaks in this runner (clients left open when a case timed out; clients registered for cleanup only after `await connect()`, plus abandoned cases still creating more). The third I misdiagnosed as machine load. The fourth attempt found the real cause: a leaked catalog lock in the engine, fixed separately, which alone accounts for the jump from 45 passes to 131. So the runner carries its own guards: per-operation CSOT timeouts so work is never abandoned, an active-handle census per file, an end-of-run tripwire for stray timers, a hard stop if the server dies rather than emitting hundreds of misleading ECONNREFUSED failures, and --skip/--limit for bisecting a run whose failures depend on position. The README states the rule plainly -- a long unbroken tail of timeouts is a harness bug until proven otherwise -- and the two commands that settle it. Also fixes bench-run.sh, which copied its report over bench-latest.txt unconditionally, including after a run that only warned -- so a degraded run could silently replace the baseline that PLAN D7.5 makes a milestone gate. |
|||
| e2c25a986b |
wire/server: honour moreToCome on OP_MSG requests
`Message.flags` was parsed and stored but never read. An OP_MSG request with
moreToCome set is fire-and-forget: the client will not read a reply. Sending one
anyway leaves it unread in the socket, so the next command on that connection
reads the previous command's reply and waits forever for its own.
This is not a corner case. Every unacknowledged write uses it, and the Node
driver sends `endSessions` with `writeConcern: {w: 0}` whenever a client closes
-- so an ordinary application that never asks for w:0 still hits it. Before:
insertOne({w: 0}) -> ok, acknowledged=false
countDocuments() (same conn) -> BSON element "cursor" is missing
The command still runs; only the reply is suppressed.
The e2e case pins maxPoolSize to 1, because with a larger pool the driver may
hand the next operation a different connection and hide the bug. It asserts the
connection still works afterwards, which is the part that matters -- not that
the unacknowledged write itself returned.
|
|||
| d867c37d32 |
commands: stop leaking the catalog lock on a nameless command
dispatch resolved the namespace *after* acquiring the catalog lock, and bailed
out with `orelse return` when either part was missing. A plain return is not an
error return, so it ran neither the errdefer nor the explicit unlocks after the
handler: the catalog lock was held, shared, for the life of the process.
`db.aggregate(...)` reaches it. That sends `{aggregate: 1}`, whose value is a
number, so str_arg returns null.
What made this hard to see is that a leaked *shared* lock is invisible to
readers. ping and listDatabases kept answering in microseconds, and the server
looked perfectly healthy from outside -- an external prober got `ok 15ms`
throughout. Only a write needing the catalog exclusive to create a collection
blocked, so the failure surfaced one command later, on a different connection,
as a client-side timeout with nothing to connect it to its cause. It cost three
invalid spec-test baselines before the driver's own command log showed an
insert sitting for exactly socketTimeoutMS against an idle engine.
Namespace resolution now happens before any lock is taken, and a missing name
is a BadValue reply instead of an empty document (which drivers render as the
uninformative "n/a").
Also fixes the aggregate path it exposed: a missing collection returned a reply
with no `ok` field, where MongoDB answers an empty cursor.
Tested by asserting both halves -- a real error reply, and that a following
write which creates a collection still completes. The second is the lock check.
Mutation-checked: reintroducing the leak reddens that test by name.
|
|||
| aee23cb028 |
index/db: enforce _id uniqueness through the _id_ index
_id uniqueness was a `coll.docs.contains` probe. The docs hashmap is going
away (PLAN A3), so it has to move to the _id_ tree -- and the tree answers
better, because it is keyed on bson.encode_key, which is canonical where
serialize_value is not. int32 1, int64 1 and double 1.0 are now one _id, as
they are in MongoDB (A4).
_id_ is built and checked first, so a write violating both it and a unique
secondary reports _id_, which is what MongoDB reports. It returns
error.DuplicateKey with `dup_index` left null, which is exactly what
commands.zig's E11000 rendering already treats as "the _id_ index", so the
wire-visible message is unchanged and that file needed no edit.
check_unique's exclude-self became optional and is null on an insert. That was
a latent bug of its own: a replace must ignore its own existing entries, but an
insert has none, and passing the document's id there hides a collision whose
entry carries that same id -- precisely the case _id_ exists to catch. Only
_id_ could reach it, since a secondary collision is between different
documents.
Two corrections found while doing this, both worth reading:
PLAN A4 claimed a database already holding {_id: int32 1} and {_id: int64 1}
loses one on reopen. It does not. Replay evicts through the docs map, keyed on
serialize_value, so both survive; the tree is bulk-built afterwards with
enforcement off, which tolerates duplicate keys and warns. The loss arrives
only with the commit that drops the map, and that is where it needs a
pre-flight scan. Amended.
dispatch_insert asserted only `ok: 1`, but a rejected document comes back as a
writeError alongside it -- so the mixed-type corpus silently shrank from ten
documents to nine when _id_ became unique, and every test over it still passed.
The helper now rejects writeErrors and asserts the inserted count; it caught
the shrink immediately. The corpus keeps an int64 _id on a distinct value, and
the collision it used to stand in for is asserted directly.
Also adds Index.lookup_exact, which the commands that currently probe the docs
map will need. Exact byte equality rather than cmp_prefix, because {a: 1}'s
encoding is a proper prefix of {a: 1, b: 2}'s and a prefix match would claim a
document is present when it is not.
Mutation-checked, all three red: unique=false on id_index; exclude=id_key on
insert; eql -> cmp_prefix in lookup_exact.
|
|||
| f61416f44a |
index/db: heap-allocate secondary indexes
Collection.indexes held Index by value, so orderedRemove memmoved the whole ~5 KB struct and every *Index already handed out referred to a different index afterwards -- a query plan's `index` field, or a slice into an index's promoted-key buffer. The collection's own bookkeeping stayed consistent, which is why nothing noticed: only a caller holding a pointer across a drop could see it, and no test did. The new test does, and it is mutation-checked against the by-value code that this commit replaces: holding pointers to b_1 and c_1, then dropping a_1, the b_1 pointer reads "c_1". Now orderedRemove moves 8-byte pointers, the surviving indexes do not move, and only the removed one is freed. M0 needs this independently: an Index will own a file mapping once the node arena moves into the data file, and copying one by value would duplicate that ownership. Not done, though the milestone plan listed it: moving Index's inline scratch and promo buffers out of the struct. Their stated purpose was to keep those 5 KB out of a file-resident Index and to stop the memmove -- but only the node arena and overflow slab become file-resident, not the Index metadata, and boxing already fixed the memmove. Moving them would be churn with nothing left to buy. |
|||
| 411a380d38 |
commands: fix a remote invalid free in aggregate $sort
Present since at least
|
|||
| 06504127fb |
index: route arena access through accessors; tighten reserve_for's bound
Groundwork for M0: the node arena and overflow slab are about to move into an mmap'd data file where a write to a page belonging to the last durable checkpoint has to copy that page first (PLAN amendment A1). Two changes make that a small commit rather than a sixty-site one, plus the reformat of this file (see the preceding style commit for why it rides along here). Accessors. Every read of a node page now goes through page(), every write through page_mut(), and every overflow read through ovf(); nothing else touches nodes.items or overflow.items. Which of the 55 sites mutate was decided by the compiler rather than by inspection -- page() returns *const Node, so every mutating site failed to compile until flipped -- and the result is that the copy-on-write hook has exactly one home. Records the rule COW will impose (never hold a *Node across a page_mut of the same id) and the audit showing today's callers already comply. Comptime layout asserts. These structures are about to become an on-disk format, and nothing pinned them. Pinning also surfaced that @sizeOf(Slot) is 32, not the 20 its 160 declared bits suggest -- the backing integer's 16-byte alignment rounds it up, so 12 of every 32 slot bytes are padding and a node holds 127 slots where 203 would fit. Pinned, deliberately not fixed: narrowing the slot changes the fanout and so the on-disk shape of every index, which belongs in the commit that reshapes leaf records. reserve_for. The old bound stood in for "levels a batch can add" with n/8, which is ~125 levels for a 1000-entry batch and demands ~528 MiB of headroom. Growing by g levels needs at least 2^g entries, so log2_ceil(n+1)+1 bounds it, giving ~70 MiB for that batch. Harmless as ArrayList capacity; real file growth once the arena is file-backed. Overrunning the reservation is a buffer overrun on a path that has already appended to the log and cannot report failure, so alloc_node and store_record now assert, using assert.zig so the checks survive ReleaseFast. Mutation-checked by dropping the reservation entirely: six tests go red with the new message. Worth noting the assert guards the allocation, not the arithmetic -- ensureUnusedCapacity over-allocates, so a slightly-too-small bound is masked until the reservation becomes exact. build.zig gains a `fuzz` step. spill, spill2, stress and fuzz_split were in no build step and are not in lib.zig's test block, so `zig build test` could not see an API break in the only coverage for records past the inline limit and for randomized split/remove interleavings -- exactly what this work puts at risk. |
|||
| 13d7b79f2c |
plan: amend the M0 decision record for copy-on-write; add AGENTS.md
The M0 implementation review found three of D1-D9 wrong or incomplete. The originals stay in place with pointers to a new amendments section, so a later session can see what changed rather than reading a rewritten history. A1: D4 as written is unsound. The doc and overflow slabs are append-only, so replay repairs them, but B+tree node pages are mutated in place -- after a crash the file holds an arbitrary mix of written-back and not-written-back pages, and once D6.3 truncates the log the data file is the only copy below the watermark. A half-persisted tree is unrecoverable. So the checkpoint needs shadow paging: no page below the last watermark's allocation mark is ever stored into, and the watermark write is the atomic switch. Knock-on: node ids cannot be page numbers, because Node.parent/next/prev are back-pointers by id and copy-on-write would cascade; an in-RAM id->page table per index keeps every persisted id at its current width and gives COW one pointer to fix. A2: follows from A1 -- a page free list is a prerequisite, not the defense-in-depth D6.2 assumed, because COW abandons every page it touches in every epoch. The churn gate stays, retargeted at document garbage. A3: section 5 step 4's trap was misidentified. store_record already copies keys, so "entries must own their key bytes" is work that does not need doing. The real problem is that a leaf record has nowhere to put a slab offset; the resolution is to make the payload that offset, in every index, and delete Entry.id rather than re-own it. A4: making _id_ a unique index keys uniqueness on the canonical encode_key rather than serialize_value, so int32 1 / int64 1 / double 1.0 collide as they do in MongoDB -- a compatibility improvement, with a documented one-way migration hazard for a database that already holds two such documents. Also records that Engine.seq is never restored on open (harmless today, silent data loss once a watermark exists), and two bugs the new spec harness found. AGENTS.md carries the same rules into the operating guide: ground rules grow from 7 to 9, and the old rule 6 is corrected with a note saying why. |
|||
| 86ae8fa8af |
style: adopt TigerStyle across src/; add docs/TIGER_STYLE.md
Wrap signatures and long expressions to the 100-column limit and make every file zig fmt clean. Semantics-preserving throughout: ignoring whitespace and the trailing commas that wrapping introduces, every file here is byte-identical to its predecessor, and the one apparent exception is a warning string split with `++`, which concatenates at comptime to the same bytes. src/index.zig and src/commands.zig are reformatted in the commits that follow, because their reformat is interleaved with in-flight changes to them and separating the two would need the reformat re-derived rather than moved. |
|||
| d4c9b04f21 |
rename project to MultiforaDB
Prose and benchmark tables use MultiforaDB; the binary, the CLI usage line, the log-message prefix and the default database file use multiforadb. Two consequences worth noting: - build.zig.zon's fingerprint is derived from the package name, so it had to change with it (Zig refuses to build otherwise). A consumer pinning this package by fingerprint needs updating. - the default --db path is now multiforadb.log, and getCmdLineOpts reports it as dbpath. An existing mongo-lite.log has to be passed explicitly with --db. The e2e harness abbreviated the old name as ML_; that is now MFDB_, including the documented ML_BIN override (MFDB_BIN) and the scratch file names. MD_ (mongod) is untouched. compare-run.sh spawned the server by absolute path under a sandbox/mongo-lite directory that no longer exists; that block already runs from tests/e2e, so it uses a relative path now. The archived reports under tests/e2e/results/ keep the old name: they record what the old binary measured. |
|||
| ac464f2b92 |
tests/e2e: iteration-to-iteration benchmark harness
compare-run.sh answers "how do we compare to MongoDB"; it says nothing
about whether a change made things better or worse than last week. Add a
harness that records each run and diffs it against the previous one.
bench-run.sh wraps compare-run.sh, adds a concurrent durable-write
comparison (concurrent.js: N clients each doing sequential insertOne with
{w:1, j:true}, exercising the group-commit path under real contention),
writes a versioned name<TAB>value report to results/bench-<timestamp>.txt,
and prints a diff of our numbers against results/bench-latest.txt.
Also:
- compare-run.sh polled with fixed sleeps, which are flaky once earlier
benchmark phases have warmed the machine; both servers now wait on a
real driver connection instead.
- a dispatch error only reached the client as a generic InternalError,
with nothing on the server side naming the failing command; log the
connection, command and error name before replacing the reply.
|
|||
| 720540860a |
db/storage: close three data-loss paths in commit and compaction
Follow-up hardening on the group-commit work from |
|||
| c8d547fef5 |
db/commands: acknowledged writes reach the disk again
Three defects, each of which made the database lose data that had already
been acknowledged, or answer a client with a malformed reply.
- Engine.commit decided a writer was "already covered" by comparing
log.end_pos with the position of the last completed commit. Under block
framing an append leaves its bytes in the log's open in-memory block and
does not move end_pos -- only sealing does. So once the first commit had
set committed_end = end_pos, every later write command found itself
covered and returned without sealing or syncing anything. A no-op
deleteMany followed by insertMany(50) was acknowledged with the file
still 16 bytes (its header) and lost all 50 documents on kill -9, which
is precisely what e2e2's crash pair does. Coverage is now decided by
sequence number, which counts records rather than bytes on disk.
- Compaction read the new log's end position before syncing it, but the
sync is what seals the open block, and the seal is what moves end_pos
past it. Appends after a compaction therefore started inside the
compacted file's last block and overwrote it, so those documents were
gone at the next replay: e2e6's phase 2 ended with 1000 documents in
memory and 996 after a graceful restart.
- cmd_find returned early on a missing namespace without putting anything
in the reply, so a find on an unknown collection arrived at the driver as
a response with no `ok` field ("MongoServerError: n/a") instead of an
empty cursor. The other commands' missing-namespace paths were fine.
Verified with the unit suite in ReleaseFast/ReleaseSafe/Debug, the split
fuzzer, all six e2e suites (e2e6 back to 72/72) and the kill -9 crash pair
-- none of which passed beforehand -- plus 13 kill -9 runs over 1/2/8
connections with 1200 acknowledged inserts each and nothing lost.
tests/e2e/results/phase7.txt records the benchmark with the fixes in place:
no regression against phase6 (bulk 739 -> 753 MB/s, updateMany 1.9 -> 2.0
ms, RSS 547 -> 546 MB), and concurrent durable writes now measurable at
7.1k/15.0k/21.8k docs/s over 1/8/32 connections.
|
|||
| ecd28d9b26 |
engine: decompose the global lock; cross-connection group commit (roadmap item 5)
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. |
|||
| 570900a6ef |
storage: byte documents in a per-collection slab (roadmap item 4)
Documents live as canonical BSON bytes in a segmented per-collection slab (fixed 8 MiB segments keep capacity slack under one segment); the docs map holds flat offsets that stay valid across segment growth, and removed documents leave garbage bytes until compaction rewrites. The per-document ArenaAllocator and its second full Pair-tree copy are gone. The matcher walks the stored bytes directly, skipping by length any field the filter does not name (a new bson byte-walker: element_key, skip_value, read_value with borrowed leaves, get_at, and a borrowed spine parse). The byte matcher is differential-tested against the tree matcher on a corpus and shares its operator logic. Stored documents are never materialized on the scan path or in aggregate $match; $group reads group keys and sums straight off the bytes. Sort, projection, findAndModify, updates and index entry generation use a borrowed spine into the slab (or the byte collector, which also replaced collect_values in build_entries). The compaction threshold now counts uncompressed data volume, since a compressed log would otherwise never trigger. Measured (tests/e2e/results/phase5.txt): server RSS 1979 -> 539 MB (2.4x smaller than MongoDB; phase1 baseline 2.0 GB), range-scan 22.5 -> ~12 ms (parity, best run faster than MongoDB), proj 4.1 -> 3.4 ms, createIndex parity. Verified: unit suite in all three modes with zero leaks, the crash pair, e2e6, and the stress/spill programs. |
|||
| b4585106f1 |
storage: block-framed LZ4-compressed log (roadmap item 3)
The log is now a 16-byte file header (magic, version, codec, block target) plus a sequence of blocks. Each block keeps the pre-existing record framing unchanged, so Engine.apply_record does not change; records never straddle blocks (appends accumulate in memory and the block seals at ~256 KiB). The block header's integrity hash covers the stored payload bytes exactly as they sit on disk, so the decompressor only ever sees input already proven intact. Torn tails stay distinguishable from interior corruption exactly as before: a short read, an impossible length, or a hash mismatch in the final block truncates cleanly (later appends overwrite the garbage); a hash mismatch anywhere else is error.InvalidLog. The codec is a hand-rolled LZ4 block compressor/decompressor (~1.7 GB/s measured) with a per-block codec byte falling back to raw when compression does not help; the header keeps raw legal so zstd can be swapped in later. Zig 0.16 ships zstd decompression only, and deflate would cap writes below the insert rate. Engine.compact goes through the same Log API (deferred sync, one commit) and compresses for free; sync() seals the pending block before fsyncing, so the acknowledged-write durability semantics are unchanged (an unsealed block holds only unacknowledged batch records). Measured (tests/e2e/results/phase4.txt): db on disk 1025 -> 97 MB, now smaller than MongoDB's own compressed files; bulk insert 816 -> 722 MB/s (the accepted compression cost); reopen unchanged at 0.8 s. Verified: unit suite in all three optimize modes (new LZ4 round-trip, corrupt-block, and torn-tail truncation tests), the crash pair, e2e6 (kill -9 mid-write), and two full benchmark runs. |
|||
| 58914a69c3 |
index: ordered _id index (roadmap item 2)
Give every Collection an implicit _id_ index (a normal Index with keys
[_id: 1]) so _id equality, $in, ranges and sorts stop depending on the
docs-map hash or a full scan. Kept out of the secondary indexes list, so
listIndexes/dropIndexes/createIndex and the log format are unchanged (no
index_create record, no double listing) and e2e3.js passes unmodified.
Maintained in upsert through the same reserve-then-insert protocol as
the secondaries, removed in evict_doc, and rebuilt after replay by
build_all_indexes alongside them (never maintained mid-replay, so a
failed add can't leave the index under-approximating). index.plan now
takes it as a separate argument. Its keys are canonical
(bson.encode_key gives int32 1, int64 1 and double 1.0 identical bytes),
so the serialization-guarded docs-map fast path (plan_id,
value_fast_path_safe and friends) is deleted.
Measured (tests/e2e/results/phase3.txt): sort({_id:-1}).limit(20) 6.2 ->
2.4 ms (2.3x slower than MongoDB -> parity); integer/string _id point
lookups, $in and ranges verified against the tree. Unit suite in all
three optimize modes, the crash pair, e2e3/e2e4/e2e6.
|
|||
| 61fe952125 |
index: B+tree over the encoded keys (roadmap item 1)
Replace Index.entries (one sorted array) with a B+tree so writes into an already-built index stop being quadratic. Nodes are fixed 4 KiB slotted pages in a flat u32-addressed ArrayListUnmanaged(Node); records longer than a quarter page spill to an append-only overflow slab (BSON strings reach 16 MB). Leaves are doubly linked for ordered iteration; the flat node array stays one contiguous byte range for a later checkpoint. Insertion descends by separator and splits leaves/internals upward, promoting keys via a stable copy (a nested split can otherwise clobber the promoted-key scratch). Deletion does not rebalance: emptied leaves are unlinked and dropped from their parent, internal nodes may carry one child, and dead pages are abandoned in place (node memory peaks at the tree's peak size, exactly what the old array's capacity did). Lookups are lower-bound seeks plus leaf-chain band scans, so equal keys may span leaves freely. Bulk build (append_doc_entries + finish_bulk) sorts a staging array and packs leaves bottom-up. reserve_for now takes the built entries and reserves exact overflow bytes plus a worst-case node count, keeping insert_entries infallible after the log append. db.zig: TTL sweep now seeks the minimum-datetime encoded key and walks the contiguous datetime band, stopping at the cutoff or type change. Measured (tests/e2e/results/phase2.txt): updateMany 17.3 -> 1.8 ms (2.8x slower than MongoDB -> 3.7x faster), createIndex 62 -> 51 ms. Verified: unit suite ReleaseFast/ReleaseSafe/Debug (incl. the existing lookup_range and remove_doc differentials, plus a new incremental insert/remove differential against a brute-force model), the crash pair, e2e3/e2e4/e2e6, and dev stress tests for depth-2 splits, full drains, and spilled records through internal levels. |
|||
| 71112b0ff7 |
ROADMAP: write up the remaining performance work
Five items in dependency order, each sized to land on its own. The design decisions already settled are recorded so they are not re-derived, and so are the ordering constraints, which are the part that actually matters -- notably that the _id index must follow the tree, because it updates on every insert and against a sorted array that is only affordable while ids happen to append at the end. Also carries the ground rules the earlier work established: A/B on one harness rather than trusting a model, mutation-check tests that guard an invariant, and the two absolutes (an index may only over-approximate; the database must always open). Each item names the traps found while investigating it -- deletion being where B+trees go wrong, listIndexes possibly noticing a real _id_ index, hashing compressed rather than uncompressed bytes, the fabricated Document values that break when Document changes meaning, and the durability guarantee that quietly weakens under group commit. |
|||
| b3b351167f |
README: current numbers and remaining gaps
The performance table, the large-collection notes and the roadmap all described the state before the optimization work. The table is the recorded Phase 1 gate run (tests/e2e/results/phase1.txt). Bulk insert, createIndex and reopen moved from losses to wins; the sort row is split, since an indexed field now streams out of the index at 1.0ms while _id still materializes. The large-collection notes carried advice that is now wrong: compaction no longer needs a raised --compact-threshold for bulk loads, and building an index over existing data is no longer quadratic (inserting into one that already exists still is). The v1 index limits no longer claim removal is O(n) or that sort cannot use an index. The roadmap becomes what is left, in order, with the reason each remaining gap is structural, plus a record of what was done and the measurement that drove it. Two more bugs join the list: compaction fsynced once per live document, and remove never checked the compaction threshold. |
|||
| 4aaa555563 |
index/commands: let an index supply the sort order
A sort ordered every match before discarding all but one page, even when
an index already held the candidates in exactly that order. The planner
now recognizes that case and the scan streams the page straight out.
An index provides the sort when the sort keys line up with the components
after the equality-pinned prefix (those are fixed to one value each, so
they do not affect the order of what follows) and every direction agrees
uniformly -- all the same way round, or all opposite, since the array can
only be read forwards or backwards. Multikey indexes are excluded: they
emit a document once per indexed value, so their order is not an order on
documents. So is an $in, whose disjoint ranges concatenate unordered.
Entries already come out of the array in key order, so forward scans were
sorted all along; what destroyed it was the dedupe pass sorting by id.
The conditions above are exactly the ones under which that pass is
skipped, so ordered output needs only reversing for a backward scan.
evaluate_index gains a plan shape it did not have: a full index scan when
the ordering is the reason to use it. Without that, find({}).sort(...)
was unreachable -- an empty filter yields no clauses and the planner bailed
before looking at any index. It is guarded to only appear when the sort is
satisfied, since otherwise scanning the docs map directly is cheaper.
The early stop had to move into the scan, which is the only place that
knows whether the order came from an index: a limit is a valid page
boundary without a sort, or with one an index supplies, and otherwise
means nothing. Getting this wrong the other way -- limiting first and
re-scanning -- would have doubled the work for every unindexed sort.
find({}).sort({k: 1}).limit(20) over 65,536 x 16 KB documents:
4.0ms -> 1.0ms
sort({_id: -1}) is unchanged at 4.3ms: nothing ordered covers _id yet.
Checked against the definition rather than by example: eleven query shapes
-- forward, backward, skip, unlimited, filtered, equality-prefixed both
directions, compound, directions the index cannot serve, and a range --
each compared through the real driver against the page of the equivalent
full sort.
Verified: 78 unit tests under ReleaseFast and ReleaseSafe, e2e
29/16/17/3/2, the crash pair, e2e6 72/72.
|
|||
| 2e508d3ecf |
index/db: locate index entries by regeneration, not by scanning
remove_id walks every entry in the index comparing ids, so evicting one
document cost O(index size) per index -- on a 65,536-document collection
that is 65,536 id comparisons to remove a single entry, and it ran on
every update and every delete.
Entry generation is a pure function of the document, so remove_doc
regenerates the entries the document contributed and binary-searches for
each one. evict_doc now removes the document from the docs map first and
hands the document itself to the index, while both it and the map key are
still alive.
updateMany({k: 7}, {$inc}) over 65,536 documents, measured A/B:
15.4ms -> 5.5ms (MongoDB 8.3.7: 6.1ms)
It is infallible by construction. Regeneration allocates and can fail,
and a document the index could not key contributed nothing to remove; in
either case it falls back to the scan, which is always correct. It also
falls back if the regenerated entries are not all found, so a
disagreement degrades to slow rather than leaving a stale index. That
last guard is defensive only -- regeneration is deterministic, so the
test below does not reach it.
The equivalence is checked directly rather than by example: two identical
indexes are built over documents exercising multikey arrays with repeats,
missing fields and both sparse settings, then emptied document by
document in shuffled order -- one through remove_doc, one through
remove_id -- asserting the entry arrays stay byte-identical at every
step. Verified it fails when removal order is reversed, which is the way
positional removal actually breaks.
Insertion still memmoves the tail. That, and ordered leaf iteration, are
what the tree is still for.
Note for later: the updateOne({_id}) and deleteOne({_id}) paths are slow
here for an unrelated reason -- an integer _id is rejected by
value_fast_path_safe, so each one is a full collection scan. The encoded
keys already make that guard unnecessary; removing it belongs with the
_id index.
Verified: 78 unit tests under ReleaseFast and ReleaseSafe, e2e
29/16/17/3/2, the crash pair, e2e6 72/72.
|
|||
| 6feacc21cd |
index: hold encoded byte keys instead of Value slices
Entry.key becomes the order-preserving byte encoding of the indexed
values, concatenated column by column, instead of a slice of Values.
Comparing two entries is now a memcmp.
The old representation allocated one Value slice per entry, and each
Value in it pointed into a different document's arena -- so a binary
search over the entry array was a chain of pointer chases across the
heap, and every comparison walked the key component by component
dispatching on BSON type. Byte keys make the comparison contiguous and
type-free, and the key no longer aliases the document at all.
createIndex over 65,536 documents:
{k: 1} 56ms -> 44ms
{s: 1} unique 53ms -> 31ms
{p: 1, k: -1} 54ms -> 37ms
(both already down from ~650ms before the bulk build)
The search API still takes Values and encodes at the call site: lookups
happen per query, not per document, so there is nothing to gain from
pushing the encoding out to callers, and Plan keeps its current shape.
Prefix search compares raw byte prefixes, which is sound because every
column encoding is self-delimiting -- a prefix of an encoded key is
exactly the encoding of its leading columns. For the same reason a
complete column encoding can never be a proper prefix of another, so
finish_bulk's duplicate test is now a plain byte equality.
The TTL sweep read entry keys as Values to find datetimes. It now uses
bson.encoded_leading_datetime, which checks the column's tag and decodes
eight bytes rather than the whole key. Still a linear walk for the reason
the existing comment gives.
Key direction is deliberately still not applied to the encoding.
Complementing descending columns would let a sort read the array
forwards, but nothing exploits that yet, and doing it now would change
the array's order for no gain. It belongs with the sort-aware planner.
remove_id is still a linear scan and insertion still memmoves the tail:
those are the tree's job, not this change's.
Verified: 77 unit tests under ReleaseFast and ReleaseSafe, e2e
29/16/17/3/2, the crash pair, e2e6 72/72, and the randomized
lookup_range test that checks bounds against a brute-force filter.
|
|||
| ea1f0f09cf |
bson: order-preserving key encoding
Encodes a Value so that std.mem.order over the bytes reproduces bson.compare exactly. This is the foundation for the encoded-key index: it lets an index binary-search, range-scan and eventually be stored as raw bytes, instead of carrying Value trees whose every comparison chases pointers into a different document's arena. Layout is [rank + 1] then a self-delimiting payload; the +1 keeps 0x00 out of the tag space so it can terminate variable-length payloads. The parts that are easy to get wrong, and why they are the way they are: Numbers encode the f128 that compare already widens int32, int64 and double to -- exactly, for all three. So int32 1, int64 1 and double 1.0 produce identical bytes, which is the cross-type equality that numeric index lookups need, and is precisely what value_fast_path_safe exists today to work around. Negatives are bit-inverted and positives get the sign bit set, making the IEEE order lexicographic. -0.0 normalizes to +0.0 (they compare equal) and every NaN encodes as all-ones (compare makes NaN greatest and all NaNs equal). Byte strings escape 0x00 as 00 FF and terminate with 00 00. A BSON string may contain NUL, so a bare terminator would be ambiguous; escaping fixes ordering at the same time, since a real NUL then sorts above the terminator and any byte >= 01 does too. "Shorter is less" falls out to match std.mem.order, which also gives documents and arrays their length tie-break for free. Binary length-prefixes because compare_binary orders by length first, but opaque_val escapes instead: compare ignores its kind and orders the data lexicographically, not by length. Correctness rests entirely on the order equivalence, so it is checked exhaustively rather than by example: every ordered pair of a corpus spanning all fifteen ranks and their boundaries (numeric cross-type and sign, NaN, both zeros, infinities, embedded NULs, prefix relationships, empty and nested documents and arrays, binary subtypes) is compared both ways. A second test concatenates two-column keys and checks they reproduce component-wise order, which is what makes compound keys and prefix search sound. Verified both fail when escaping is dropped, when -0.0 is not normalized, when the binary length prefix is wrong, and when NaN stops being greatest. Nothing uses the encoding yet; the index still holds Value keys. |
|||
| 75e412a4af |
query/commands/wire: trim the scan and request paths
Matching allocated an ArrayList per filter field per candidate document,
on the process-wide allocator, to hold what is almost always a single
value. Candidates now collect into a stack buffer that spills to the heap
only for arrays: measured 15.7 -> 12.0ms on a 65,536-document range scan.
The OOM-propagation test moves with it. Its point is that a failed
collection must surface as an error rather than an empty candidate list,
which would make $ne and $exists:false report a match -- a wrong answer
rather than a failed one. That invariant still holds on the spill path, so
the test now uses an array long enough to reach the allocator, and a new
test pins the flip side: the common single-value match now completes
correctly even when the allocator always fails, because it never calls it.
Query operators were dispatched by a chain of up to fourteen mem.eql per
value per document, with $gt/$gte/$lt/$lte re-comparing the operator name
inside the loop over candidate values. Names resolve to an enum once per
filter field. Command dispatch likewise walked a 30-entry table comparing
strings; it is a comptime StaticStringMap now.
Each request built a fresh reply arena and handed its pages straight back.
One reply per connection, reset between requests, keeps them.
countDocuments() arrives as [{$match: F}?, {$group: {_id: <literal>,
n: {$sum: 1}}}], which the general path answered by materializing every
matching document and discarding them all. It is now recognized and
answered from a counting scan: countDocuments({}) 2.3 -> 1.5ms.
The detector is deliberately conservative -- grouping by "$field", summing
a field, an unmodelled accumulator or any extra stage all fall through to
the general path, since those need the documents themselves. A unit test
pins each accept and reject, and the whole count path was checked against
the general one through the real driver, including the shapes that must
not take it.
The filtered range-scan row does not move: it is bound by walking 65,536
documents that each live in their own arena, not by the matcher. That is
Phase 4 work.
Verified: 77 unit tests under ReleaseFast and ReleaseSafe, e2e
29/16/17/3/2, the crash pair, e2e6 72/72.
|
|||
| 552b916833 |
tests/e2e: record the Phase 1 gate measurement
Reproduce with bash tests/e2e/compare-run.sh 1g 16k. Without a stored baseline the phase gates in the plan are not checkable and the projected numbers are not falsifiable. |
|||
| 9eecb5092c |
query/commands: top-k sort selection and an allocation-free decorate pass
sort+limit ordered the entire result set to return one page: 65,536
documents sorted to hand back 20. Two independent costs.
The decorate pass built, per document per sort key, an ArrayList of every
value at the path -- but the comparator only ever reads element 0. Added
first_value_at, which mirrors collect_values' traversal exactly (same
order, same depth cutoff) and stops at the first hit, and moved the
decorated values into one flat allocation. That equivalence is the whole
correctness argument, so it is pinned by a test covering dotted paths,
arrays of documents, numeric element addressing, repeated keys, missing
paths and the depth cutoff.
sort_docs_top_k keeps a k-element max-heap instead of ordering
everything: one comparison against the heap root per document, and only
the survivors are ever sorted. cmd_find uses it when the page is at most
a quarter of the matches, where the heap's bookkeeping still pays for
itself, and falls back to a full sort otherwise. It leaves docs[k..]
unordered, which is safe because the page is a prefix of the first k.
cmd_aggregate's $sort is deliberately untouched: a later stage can read
the whole stream, and top-k would silently corrupt the tail.
find({}).sort({_id:-1}).limit(20) over 65,536 x 16 KB documents:
baseline 40.0ms
decorate only (top-k disabled) 23.9ms
decorate + top-k 4.3ms
Correctness checked end to end as well: the limited page is identical to
the prefix of the equivalent full sort. The top-k test compares against a
full sort across ascending, descending and compound keys, for k of 1, 2,
20, n-1, n and n+1, over data with heavy ties; verified it fails when the
heap's child comparison is inverted.
|
|||
| ff37e6c813 |
index/commands: bulk index build, binary-searched ranges, limit push-down
createIndex built the entry array one document at a time, and each
insert kept the array sorted by memmoving the tail -- O(n^2) bytes moved
over a full build, which was the entire cost of the operation. Entries
are now appended unsorted and ordered once (append_doc_entries +
finish_bulk), with uniqueness checked by a single adjacent-pair scan
instead of a binary search per document. build_all_indexes, which runs
for every index on every open, takes the same path.
createIndex over 65,536 documents, measured A/B:
{k: 1} 649ms -> 56ms
{s: 1} unique 678ms -> 53ms
{p: 1, k: -1} 653ms -> 54ms
lookup_range binary-searched only the equality prefix and then scanned
that whole band applying a filter, so a range on the first component of
an index touched every entry in it. Both ends are now binary searches
over the component the array is already sorted on, clamped into the
equality band. Note this does not move the range-scan row in compare.js:
that query filters on p, which has no index there, so it is a collection
scan and belongs to the matcher.
cmd_find passed a hardcoded 0 as the scan limit, so find().limit(n)
materialized the entire collection before slicing. It now stops once the
page is filled, when there is no sort to order the matches first; the
bound covers the skipped prefix because the scan counts matches rather
than returned documents.
lookup_range's bounds are checked by a new randomized test that compares
the result count against a brute-force filter over 600 generated
queries, with values chosen from a small domain so equal keys and the
inclusive/exclusive edges come up constantly. Verified it fails when
either bound is swapped.
|
|||
| 556ad7dc86 |
storage/db: XxHash3 record integrity, garbage-ratio compaction
Two streams of work land together: they are interleaved in storage.zig
and db.zig and only build as a unit.
Already in the working tree before this session:
- ReleaseFast as the default zig build (Debug was 10-200x slower)
- group commit: one fsync per write command instead of per document
- plan_id returned a pointer to a stack temporary; ReleaseFast read
garbage and silently broke findOne({_id: ObjectId})
- perf suite: big.js, compare.js, compare-run.sh, e2e6.js
Phase 1 performance work:
Record integrity hash CRC32 -> XxHash3. std.hash.Crc32 is the
table-driven byte-at-a-time Crc32IsoHdlc, measured at 408 MB/s against
XxHash3's 31 GB/s: 38us versus 0.5us on a 16 KiB document, which was
about two thirds of the entire bulk-insert cost. The record header
grows from u32 crc to u64 hash (header_len 20 -> 24), a breaking
format change. Bulk insert 260 -> 700 MB/s, reopen 1.1 -> 0.5s.
Compaction fsynced once per live document, because Log.open leaves
defer_sync false and compact never set it. It now issues one sync for
the whole rewrite, before the rename that publishes it.
Compaction triggers on the share of the log that is garbage
(live_docs/dead_docs, maintained at evict_doc, the single point where
a document dies) rather than on bytes appended. A fixed byte count is
wrong in both directions: a 1 GB bulk load holds no garbage at all yet
would compact ~64 times under the 16 MiB default, rewriting 1 GB each
time, while a small collection rewritten in place accumulates garbage
indefinitely without ever reaching the count. Pure inserts now never
compact, and the file stays near 1.25x the live data. Bulk load at the
default threshold: 41.6 -> 702.7 MB/s.
remove() never called maybe_compact, so a delete-heavy workload grew
the log without bound.
e2e6's compaction check required the file to bloat past 30 MiB before
being reclaimed, which encoded the old policy and failed on strictly
better behaviour (ends at 15.8 MB against ~12 MB live, was ~30 MB). It
now asserts the file ends near the live size and peaked well above it,
which does not depend on when the trigger fires. Sampling interval 50
-> 10ms: the operations now finish inside the old window.
Verified: 70 unit tests under both ReleaseFast and ReleaseSafe, e2e
29/16/17/3/2 checks, the crash-a/kill -9/crash-b pair, and e2e6 72/72
across three consecutive runs.
|
|||
| d90cde394c |
commands/e2e: drop topologyVersion from the handshake; rename to mongo-lite
Advertising topologyVersion in the hello reply is what tells a driver the server speaks the streaming (awaitable) hello protocol — in the Node driver it is the only condition checked. From the second heartbeat on, the driver then monitored with an exhaust hello (exhaustAllowed + maxAwaitTimeMS) and waited for a stream of replies carrying moreToCome. We answered once with the flag clear and went back to reading, so every heartbeat failed with "Server ended moreToCome unexpectedly", destroying the connection and clearing the pool. MongoDB Compass showed this as a connect/disconnect loop once per heartbeat. We do not implement streaming hello, so we must not claim to. Omitting the field keeps monitoring on the polling path, and agrees with the maxWireVersion 8 we report: streaming hello arrived in wire version 9. The existing e2e files all passed against the broken server — they issue their commands and exit before the second heartbeat — so e2e5 watches SDAM heartbeats on an idle connection instead. Also renames mongo-light to mongo-lite throughout (binary, log messages, docs, gitVersion). Unrelated to the fix above, but squashed in at request rather than left as a commit whose message described only the fix. |
|||
| dc92064b95 |
docs: TTL limits and error codes in the README
The TTL feature commit documented what the option does but left the
limits sections stale. v1 limits now names the sweep cost (a full walk of
every TTL index entry, under the write lock for the whole pass), so the
interval reads as the tuning knob it is; "Not (yet) implemented" gains
collMod, with the consequence — drop and re-create to change an expiry.
The TTL bullet trades its collMod sentence for the two codes a user
actually hits (IndexOptionsConflict 85 on a changed expiry,
InvalidIndexSpecificationOption 197 on {_id: 1}), the features bullet
mentions the sweeper, and quick start shows a createIndex with
expireAfterSeconds so the feature is visible without reading down.
|
|||
| 3c1ab6f656 |
index/db/commands/server: TTL indexes
createIndex({expireAt: 1}, {expireAfterSeconds: 60}) now deletes a
document once its indexed date is that many seconds old.
index.zig carries the option: Index.ttl (?i64), parsed from
expireAfterSeconds (int32/int64/integral double, within MongoDB's
[0, 2147483647]; 0 means "expire at the stored instant"), emitted by
spec_pairs and so persisted through the log and reported by listIndexes,
and compared by spec_equal — a same-name re-create with a different
expiry stays IndexOptionsConflict, as in MongoDB, since collMod does not
exist here. The bound keeps the value an int32 on the wire and makes the
emission cast unconditional. TTL is single-field only (a compound key is
error.TtlOnCompoundIndex); an absent option parses to null, so every
existing log record reparses unchanged.
db.zig sweeps: Engine.ttl_sweep(now_ms) walks each TTL index's entries
and deletes through the ordinary remove path, so an expiry is logged and
fsynced like any other write and holds across a restart. Expired ids are
duped before removal — remove frees the docs-map key that Entry.id
aliases — then sorted and deduped, because one document can be expired
by several entries (an array of dates expires on its earliest member,
which multikey expansion gives for free) or by several TTL indexes.
Selecting entries is a type test rather than a range lookup: bson
compare order ranks datetime above null, numbers and strings, so a
datetime upper bound would also select every value of a lesser type. A
sweep that deleted something checks the compaction threshold, since a
TTL-only workload never reaches the one in upsert.
commands.zig maps the new spec errors: CannotCreateIndex (67) for a
compound key or an out-of-range expiry, and InvalidIndexSpecificationOption
(197) for an expiry on {_id: 1}, which the idempotent _id no-op would
otherwise swallow.
server.zig runs the monitor as a member of the connection group, so the
existing group.cancel tears it down; it sleeps first, then sweeps under
the write lock, and logs rather than dies on a sweep failure.
--ttl-sweep-secs sets the interval (default 60, 0 leaves it unspawned).
Expiry is coarse by design, as in MongoDB: a document stays visible
until the next sweep, and a non-date value at the indexed path never
expires. Unit tests cover the spec round-trip and every rejection, the
sweep (inclusive cutoff, string/missing/future values untouched, second
sweep a no-op) and its survival of a reopen, and two TTL indexes over
one collection. e2e4.js exercises it through the Node driver.
|
|||
| 7482042f34 |
index/db/commands: fold duplicated index logic into single definitions
Cleanup pass over the secondary-index feature. add_doc is now the one entry-commit path. create_index and build_all_indexes each hand-rolled build -> check_unique -> reserve -> insert, and had already drifted on whether multikey is set before or after the unique check; add_doc gained an enforce_unique flag so the rebuild path keeps its tolerate-and-warn behavior. reserve_for and insert_entries are now the only way the engine touches Index.entries. One definition each for: prefix comparison and the prefix binary searches (prefix_order + std.sort), the cartesian-product odometer (advance_choice), the spec pair list (write_spec builds on spec_pairs, so the log format and the listIndexes reply share one schema), the _id clause parser (plan_id reuses analyze_clause), key-pattern direction (index.descending, which desc_dir already disagreed with on non-numeric values), option truthiness (query.truthy), the E11000 message, and index-removal-by-name (Collection.find_index/remove_index). Key-pattern matching moved out of the dispatcher into index.find_by_key_pattern. Dead or redundant: ParallelArraysError, the unread `dropped` counter, insert_entries' discarded gpa, a third pass computing multikey, the has_id/is_id_index flag pair, Plan.key_len (always lookup_keys[0].len, now a method), first_match_consumed (now stages = stages[1..]). Cheaper hot paths: remove_id compacts in one pass instead of an orderedRemove per hit; Plan.search skips the sort/dedupe when neither multikey nor multiple lookup keys can produce a repeat; the _id fast path reuses one scratch key buffer (bson.write_serialized_value); the plan loop uses the bound collection instead of re-resolving it through two hash lookups per candidate. Behavior is unchanged except that dropping plan_id's fixed 16-clause buffer enables the _id fast path on filters that previously exceeded it. |
|||
| 0d264c6c57 | index: guard the $in cartesian cap against u64 overflow; drop debug print | |||
| adcf0014a9 |
docs/e2e: indexes out of 'not implemented'; e2e3 covers driver index APIs
README documents supported key patterns, unique/sparse/multikey behavior, planner rules (multikey two-bound range fallback, sparse/null bail, the _id fast-path guards), and v1 limits plus the two pre-existing issues the work surfaces (drop-collection resurrection, compact log_bytes). e2e3.js exercises createIndex/getIndexes/dropIndex/dropIndexes, the unique-constraint 11000 path, compound, sparse, and descending indexes through the official Node driver. |
|||
| 7f2f7c6977 |
commands: createIndexes/listIndexes/dropIndexes + planner wiring
Adds the three driver commands, parameterized E11000 messages (engine dup_index carries the index name into writeErrors), the scan_matching planner wiring (_id fast path → index plan → scan) and the first-$match aggregate pushdown. The equivalence test (mixed-type corpus x 27 filters, non-sparse and sparse indexes) drove out three real bugs: the two-bound range under-approximation on multikey indexes (fall back to scan), a dangling single-value option array in the planner, and update-time unique violations now reporting writeErrors instead of corrupting state. |
|||
| a733fc1993 |
db: engine maintenance for secondary indexes
Collection gains an indexes list; evict_doc removes entries at the single document-death chokepoint; upsert does build → check → reserve → log → evict → publish so entry insertion after the append is infallible and a rejected unique write never reaches the log. Replay registers empty indexes from create/drop records (types 3/4) and Engine.open rebuilds them from live docs. compact re-emits index-create records. Engine gains create_index/drop_index and dup_index for E11000 naming. |
|||
| a38ddc2f50 |
index: secondary index core — entries, search, planner, _id fast path
Adds src/index.zig with the full secondary-index machinery: entry generation mirroring field_matches (array value + elements), BSON-order sorted entries with binary search, compound prefix and range lookups, unique/sparse options, the query planner (longest equality/$in run + optional range, $in cartesian cap, sparse/null bail), and the _id_ fast path guarded against serialization-ambiguous values (numbers, strings, symbols, codes, opaque payloads). query.collect_values is now pub so entry generation can mirror it exactly. storage.zig gains record_type_index_create/drop; lib.zig exports index. |
|||
|
|
662df9b121 | tests/e2e: pin driver deps (package.json/package-lock.json); ignore node_modules |