Aleksey Shakhmatov 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.
2026-08-02 19:56:33 +03:00

mongo-lite

A lightweight, embedded MongoDB-compatible document database written in Zig 0.16. Like SQLite, it stores everything in a single file; unlike SQLite, it speaks the MongoDB wire protocol, so real clients — mongosh, the Node.js driver, PyMongo — connect over TCP and just work.

Quick start

zig build                # build the server
zig build test           # run the unit test suite

zig-out/bin/mongo-lite --port 27017 --db data.log --compact-threshold 256m

# in another terminal:
mongosh --port 27017
> db.users.insertOne({name: "alice", age: 30})
> db.users.find({age: {$gt: 25}}).toArray()
> db.users.updateOne({name: "alice"}, {$set: {vip: true}})
> db.users.deleteOne({name: "bob"})
> db.sessions.createIndex({expireAt: 1}, {expireAfterSeconds: 3600})

Features

  • Wire protocol: OP_MSG (2013) plus legacy OP_QUERY/OP_REPLY (2004/2001) for the driver handshake; hello/isMaster with maxWireVersion: 8, so modern drivers (Node, Python, mongosh) connect without workarounds.
  • BSON: full parse/serialize round-trip for all common types (including binary, regex, timestamps, ObjectId), canonical MongoDB comparison order for sorting and range queries.
  • CRUD: insert, find (filter, sort, skip/limit, projection), update (multi/upsert), delete, findAndModify, count, aggregate ($match, $sort, $skip, $limit, $project, $count, $group with $sum), plus create/drop/listCollections/ listDatabases/dropDatabase.
  • Query operators: $eq $ne $gt $gte $lt $lte $in $nin $exists $regex (hand-rolled engine: anchors, ., * + ?, character classes, groups, alternation, i/s options) $not $and $or $nor $size $all $elemMatch, with dot paths and array multikey semantics.
  • Secondary indexes: createIndex/listIndexes/dropIndex via the three driver commands, single-field and compound, with unique, sparse and expireAfterSeconds (TTL) options, persisted in the log and rebuilt on open (compaction re-emits them). A background sweeper expires TTL-indexed documents through the ordinary logged write path. The query planner turns equality / $in / range predicates into index lookups across find, count, update, delete, findAndModify, and a leading $match in aggregate; every candidate is re-checked against the full filter, so an index that over-approximates is merely slow, never wrong.
  • Update operators: $set $unset $inc $push ($each) $pull $rename, with dot-path creation (including array indices).
  • Storage: append-only record log (CRC32-checked, fsync per write, torn-tail tolerant) with in-memory indexes rebuilt on open and automatic compaction (rewrite + atomic rename when the log grows past --compact-threshold, default 16 MB). Killed mid-write (kill -9), the database recovers all committed writes; the log and compaction both work with relative or absolute --db paths. Records up to the announced 16 MB maxBsonObjectSize replay correctly.
  • Concurrency: a writer-preferring read/write lock splits command execution — reads (find, count, aggregate, list*) run concurrently across connections, writes (CRUD, DDL) are exclusive and totally ordered, and handshake/no-op commands run lock-free. The log append + fsync still happen under the write lock, so the crash guarantees are unchanged. Fine for light workloads.

Layout

src/
  bson.zig     BSON parse/serialize, ObjectId, canonical comparison order
  wire.zig     OP_MSG/OP_QUERY framing, message + reply builders
  commands.zig command dispatch (hello, CRUD, aggregate, admin, indexes)
  server.zig   TCP accept loop, per-connection handlers, TTL sweep monitor
  db.zig       in-memory engine: db → collection → _id → document maps
  storage.zig  append-only log: records, replay, CRC validation
  query.zig    filter matcher, regex engine, sort, projection
  index.zig    secondary indexes: entries, search, query planner
  update.zig   update operators with dot-path navigation
  main.zig     CLI: --port, --bind, --db, --ttl-sweep-secs, --compact-threshold

Indexes

collection.createIndex({field: 1}) works against every driver; the index is persisted in the log, survives restarts and compaction, and is used by the query planner to narrow scans.

  • Key patterns: single-field and compound (up to 32 fields), each key 1 or -1. Descending order is metadata (entries are always stored value-ascending); the default index name is MongoDB's a_1_b_-1. createIndex({_id: 1}) is an idempotent no-op — the docs map is the _id_ index — and dropIndex("_id_") errors.
  • Options: unique (a conflicting write fails with E11000 naming the index; per-document entries are deduped first, so {a: [1,1]} is legal) and sparse (documents missing an indexed field are skipped).
  • TTL: createIndex({expireAt: 1}, {expireAfterSeconds: 60}) deletes a document once its indexed date is that many seconds old. A background sweeper runs every --ttl-sweep-secs seconds (default 60, 0 disables it) and deletes through the ordinary write path, so each expiry is logged and fsynced and holds across a restart. As in MongoDB the option is single-field only (a compound key is CannotCreateIndex, code 67), expireAfterSeconds must be a whole number in [0, 2147483647] (0 means "expire at the stored instant"), a non-date value at the path never expires, an array of dates expires on its earliest member, and expiry is coarse: a document stays visible until the next sweep. Re-creating an index with a different expiry is IndexOptionsConflict (85) and an expiry on {_id: 1} is InvalidIndexSpecificationOption (197), both as MongoDB has them.
  • Multikey: an array at an indexed path is indexed as a whole and element-wise, mirroring the query matcher exactly, so both {tags: "a"} and {tags: ["a","b"]} hit the index. A compound index over two array paths rejects the document with MongoDB's "cannot index parallel arrays".
  • Planner: picks the index covering the longest leading run of equality/$in predicates (cartesian product capped at 100 lookups), optionally with a range on the next key. Ranges with both bounds fall back to a scan on multikey indexes (a doc with {a: [1,2]} can satisfy {a: {$gt: 5, $lt: 25}} across two entries), and sparse indexes are never used for null-valued predicates. The _id_ fast path resolves {_id: ...} through the docs map unless the value's compare class is serialization-ambiguous (int32 1, int64 1, double 1.0 compare equal but hash differently — those fall back to a scan, as do string/symbol/code).

v1 limits: no index-accelerated sort, no hashed/text/geo/partial indexes, and entry insert/removal is O(n) (a sorted array) — fine for a light database, with a B-tree or id→entry map as the follow-up. A TTL sweep walks every entry of every TTL index and holds the write lock for the whole pass, so the interval is the tuning knob: expiry is never more precise than --ttl-sweep-secs, and a very large TTL index wants a longer one.

Not (yet) implemented

  • Authentication (SCRAM) — run without credentials
  • Real cursors (all results are returned in one batch, cursor id 0)
  • Transactions, change streams, replicasets
  • Compression (OP_COMPRESSED)
  • collMod, so an index's expireAfterSeconds cannot be changed in place — drop the index and re-create it with the new expiry
  • dropCollection/dropDatabase write no log record, so a dropped collection (and its index definitions) resurrect on restart

Working with large collections

Everything lives in RAM (db → collection → _id → document maps) and every write command is logged with fsync before it is acknowledged (one sync per command via group commit — a 500-doc insertMany syncs once, not 500 times), so multi-GB collections work, with cost/behavior notes measured by the tests/e2e/big.js harness (12-core/32 GB Mac):

  • Build in ReleaseFastzig build defaults to it. A Debug server is 10-200x slower on every path (the matcher alone was 70 µs/doc in Debug vs 0.4 µs in ReleaseFast), which dwarfed every other difference in the MongoDB comparison below.
  • Compaction is O(n²) under the default 16 MB threshold. A compaction rewrites the whole log (one fsync per record), so bulk-loading 5 GB with the default threshold degrades from ~310 MB/s to a crawl as the dataset grows. Raise --compact-threshold for bulk loads — e.g. 2g — and the rate stays flat. The 5.37 GB run (40,960 × 128 KB docs, ObjectIds, ReleaseFast) inserted in 36.5 s at ~310 MB/s between the two threshold compactions, peaked at 5.25 GB RSS (~0.98x the data size at 128 KB docs), and reopened the 5 GB log in 13.8 s.
  • findOne({_id}) is O(1) only for ObjectId ids. Integer, int64 and double ids compare equal but hash differently, so the docs-map fast path is skipped and every _id lookup becomes a full scan. Use the driver's default ObjectIds (or a secondary index) on big collections.
  • Secondary-index entry insert is O(n) (sorted array — see v1 limits above), so creating an index over existing data or inserting with an index in place is quadratic. Create indexes after the load.

Performance vs MongoDB

tests/e2e/compare-run.sh runs the same driver workload (1 GB, 65,536 × 16 KB docs, every write durable — mongo-lite fsyncs per command, mongod runs with j: true) against each server and prints a side-by-side table. With the ReleaseFast default build (MongoDB 8.3.7 on the same Mac):

benchmark mongo-lite mongodb winner
insertOne (sequential) 0.2 ms 4.9 ms mongo-lite ×24
bulk insert (insertMany) 267 MB/s 690 MB/s mongodb ×2.6
createIndex({k: 1}) 0.66 s 0.08 s mongodb ×8
countDocuments({}) 2.5 ms 11 ms mongo-lite ×4
findOne({_id}) 0.6 ms 0.7 ms mongo-lite
findOne indexed 0.7 ms 2.6 ms mongo-lite ×4
range-scan count 25 ms 13 ms mongodb ×2
sort + limit(20) 40 ms 2 ms mongodb ×20
aggregate $group 12 ms 13 ms mongo-lite
updateOne({_id}) 0.18 ms 0.21 ms mongo-lite
updateMany (65 docs) 20 ms 6 ms mongodb ×3
deleteOne + insert 0.9 ms 5 ms mongo-lite ×6
server RSS 2.0 GB 1.3 GB mongodb (×0.65)
kill -9 → reopen 3.8 s 1.3 s mongodb
db on disk 1.0 GB 89 MB mongodb (compressed)

The pattern: mongo-lite wins every latency-bound single-op (no network of index hops, no journal latency, in-RAM) and loses the throughput-bound bulk paths and the ops MongoDB accelerates with disk indexes and compression.

Suggested improvements (highest impact first)

  1. Index-accelerated sort — the worst gap (×20): sort+limit sorts every document. Stream candidates in index order (the planner already has ordered range search) and stop at limit. Fixes the biggest read regression.
  2. Batch index buildscreateIndex inserts entries one at a time into a sorted array (O(n²) memmoves). Sort all entries once and append in bulk (O(n log n)); a B-tree or id→entry map removes the O(n) entry insert on the write path too.
  3. Compress the log — the db is 11× MongoDB's on disk because payloads are stored raw. Snappy per record (like the wire protocol's OP_COMPRESSED) would shrink highly-compressible workloads massively.
  4. Faster reopen — replay is a full re-parse of every record. A periodic checkpoint record (or a parallel replay) would cut the 3× restart gap.
  5. Trim the write path — bulk insert (×2.6) is now bound by per-doc parse/serialize/map-put, not fsync. A pooled per-connection arena for owned docs and a bulk-insert fast path would close most of the gap; updateMany's per-doc replace-serialize is the same story.
  6. Range-scan matching (×2) — the matcher allocates a candidates list per field per doc; a stack buffer for the common single-field case removes it.

Two real bugs were found and fixed while benchmarking:

  • plan_id returned a pointer to a stack temporary (&.{e}) that dangled after the frame returned — Debug tolerated it, ReleaseFast read garbage, silently breaking every findOne({_id: <ObjectId>}). It now heap-copies the lookup value and frees it.
  • Multi-doc writes fsynced once per document; they now group-commit (one fsync per command, same crash guarantees — verified by the kill -9 crash suites).

Code style

Zig 0.16 idioms (std.Io threaded through everything, unmanaged containers); user-declared functions use snake_case per this repo's house style.

Description
No description provided
Readme MIT 522 KiB
Languages
Zig 82.8%
JavaScript 15.5%
Shell 1.7%