mongkn is a MongoDB driver for Kotlin/Native. By the time I moved a real service onto it, it had 294 tests and passed 71 scenarios from MongoDB's own specifications.
Four bugs were waiting anyway. Not one of them threw. Every one returned a plausible answer that happened to be wrong.
The most expensive four lines
The query was ordinary:
collection.find { Landing::shopId eq shopId }
shopId is a String in Kotlin and an ObjectId in the database — a custom serializer converts it on the way in. But the filter builder encoded by the value's runtime type, not by the field's serializer. So the filter carried a BsonString and the stored field was a BsonObjectId.
Mongo compared them, found nothing, and said so. Empty result, no error, no warning. The query was valid; it just asked about a field that never has that type.
The fix is to encode a filter value through the serializer of the field it is compared against, which needs childSerializers() on the generated serializer. Four lines. Days to find, because nothing anywhere was red.
Three more of the same shape
- A missing
_idmapping. Without@SerialName("_id")the class writes an ordinaryidfield and Mongo generates its own_idbeside it. Two identifiers, one document, and lookups by the one you care about quietly miss. - A sparse index that indexed everything. An explicit
nullis written asBsonNull, which is a present value. Sparse means "skip documents without the key", and every document had the key.@EncodeDefault(NEVER)is what makes absent mean absent. - A TTL index that stopped deleting. Timestamps stored as ISO strings rather than BSON dates. The index exists, it is valid, and it never expires a thing.
The defence that was already there
The library already refused one class of mistake. A property reference in a filter is checked against the class descriptor, so a renamed field fails immediately with a message that says which one. That check works — nobody had ever been bitten by it, which is the point.
So the fix was not to invent a new mechanism. It was to extend the one that already earned its place: validate the value the way the field was already validated.
A rule went into the test docs at the same time:
Native storage layers are verified by integration tests against a real
mongod, not by unit tests. Everything here that could break broke silently, and only a real server showed it.
A ladder is an upper bound, not a promise
While measuring, I built a five-level performance ladder to see what each layer costs. The coroutine channel came out at 0.22 µs per document. Batching 64 documents across it was predicted to save that 0.22 µs and actually saved 0.47 µs on macOS and 0.30 on Linux — the extra came from context switches that disappeared with the crossings.
Then a decoding optimisation predicted to save 0.43 µs delivered 4 % on the real read path, despite being 37 % faster in isolation.
That is the lesson, and it generalises past this project:
The cost of a layer in a ladder is an upper bound on the win, not the win. A layer that is not on the critical path gives you nothing when you delete it.
The baseline that moved
A bare C loop — the floor, the thing that cannot get faster — measured 6667 µs in one run and 10710 µs in another on the same machine. A 1.5× swing in the control.
Comparing numbers across runs was meaningless, and I had been doing exactly that. Interleaving the rounds instead of finishing one level before starting the next made the results reproducible, because every level then absorbed the same machine noise.
The check that was not running
A test had been failing on one platform for weeks. It was known, it was tolerated — and because it failed, the ABI validation task never ran. The public API drifted for weeks behind a red light everyone had learned to read as normal.
Two changes: the reference dump moved to the platform that actually ships (linuxX64), and the red test was closed by asserting a weaker property that holds on both branches.
What I would tell myself a year earlier
- Look for the shape — a wrong answer with no exception — before it costs you data.
- Extend a defence that already works rather than adding a new kind.
- Move a real service onto it early. Specification scenarios do not cover the four bugs above, because none of them are protocol violations.
- Verify measurements under the conditions you will actually run in.
Source: github.com/youndie/mongkn