Varan reads every document to build an exact schema. That is the product — and it was also, measurably, the thing that would have made a DBA switch it off. This is what it cost, what it costs now, and what we gave up to get there.
Every benchmark we had measured how long we waited. A DBA does not care how long you waited. They care how much of their server you consumed, and that number lives in system.profile — the same place they look when something is eating a secondary.
Measured that way, on 192,387 real GitHub events (621 MB, only the _id index, mongod 7.0 / WiredTiger):
| Exact schema census | Before | Now |
|---|---|---|
| mongod CPU | 16,217 ms | 195 ms |
| Documents examined | 5.13× the collection | 1.01× |
| Cursor held open (full read) | 108,668 ms | 16,160 ms |
| Fields found | 950 | 950 — identical |
83× less server CPU for a byte-identical census — 0 presence differences, 0 type differences, 0 array-element differences across all 950 paths. The census now costs mongod roughly what a plain find({}) over the same collection costs, which is to say: about what your nightly backup already costs.
The old census ran a recursive $objectToArray / $reduce pipeline, so mongod evaluated an expression per field, per document — roughly 1 µs per field. Restructuring the pipeline did not help: $facet, a single-pass tree walk and shape-grouping measured within 1.25× of each other.
What moved it was not making the server faster. It was not asking the server. Varan now reads raw BSON off the wire and walks it itself, so mongod does a plain sequential scan and nothing else.
The trade, stated plainly
64 MB of network to save 16 core-seconds of someone else’s CPU. Inside a VPC that is free; over a thin WAN link it is not, and the server-side engine is one setting away. Bandwidth does not get your credentials revoked. CPU does.
Hydration read a batch, wrote it into DuckDB, then asked for the next one — so a server-side cursor, and the WiredTiger read snapshot behind it, stayed pinned for the whole load. 108 seconds on a collection that takes 16 seconds to read. Wall-clock never showed it, which is why no benchmark had caught it.
Not by design. Each low-cardinality field triggered its own distinct(), and each of those is a full collection scan. 1,538,126 documents examined for a 300,000-document collection. Those value sets are now collected during the walk that was already happening: 5.13× → 1.01×.
A WHERE clause is now translated into a MongoDB $match where — and only where — the translation is provably sound.
| Predicate | Rows fetched | Less wire |
|---|---|---|
type = 'ForkEvent' AND public = 'true' | 1,891 | 61.3× |
type IN (…) | 9,589 | 14.1× |
created_at >= '2024-01-01' | 28,279 | 6.7× |
actor.id > '20000000' — refused | all | 1.0× |
That last row is the interesting one. Varan stores document values as text unless the census proves a field holds exactly one type. Over a text column, '10' sorts before '5', while MongoDB compares numbers — so the two disagree and an ordered predicate must not be pushed. A differential test caught exactly this: an early version pushed it anyway and lost 13,898 of 102,127 matching rows. Fetching too much costs time. Fetching too little is a wrong answer.
A cached copy has to know when it is stale. The textbook answer is a change stream, which requires the customer to run a replica set. Varan’s promise is that you install it and it works against what you already have, so that is not an answer.
Instead we count the rows matching the same predicate the copy was loaded under, and compare. Verified on a standalone mongod, no oplog, no extra privileges:
| Change at the source | Result |
|---|---|
| Row updated into the query’s scope (its key is old) | detected |
| Insert with a non-monotonic key (UUID) | detected |
| Delete inside the scope | detected |
| In-place edit, membership unchanged | detected (checksum) |
| Nothing changed | copy reused, 0 bytes read |
Every operation Varan issues carries a comment. Not “an app is here” — which collection, which run, and which of four partition workers, so a 40-second aggregation can be identified and killed selectively instead of the host being blocked:
varan v=0.7.0 op=census-scan db=analytics coll=events run=a3f9 part=2/4
Reads default to secondaryPreferred, all operations share one concurrency cap per cluster, and there is a stop button.
These are on the page for a reason. A benchmark without them is marketing.
_id index. Absolute figures will differ on a loaded replica set; the per-document costs are the stable part.mongod CPU figures after the fix are small (50–200 ms) and therefore noisy. The before figure (16.2 s) is large and stable. Read the ratio as “two orders of magnitude”, not as three significant figures.Not that reading a document store is free. An exact census must see every document, and no amount of engineering removes that. What changed is whose CPU does the work: mongod now does the cheapest thing it knows how to do — a sequential scan — and everything else happens on the machine that asked the question.
Reproduce it
Every figure ships as a script in the repository: benchmark/dba-cost.mjs, census-engine-cost.mjs, spool-cursor-lifetime.mjs, pushdown-differential.mjs, staleness-holes.mjs. The differential benchmarks fail loudly rather than reporting a speed-up, which is how the missing-rows bug above was found.