Each of these concepts is small on its own. The interesting design question is making them compose without each one needing to know about every other.
When MiniKV’s public API grew from Put/Get/Delete to include
context, batches, snapshots, iterators, and transactions, the
challenge wasn’t implementing any one of them — it was keeping them
orthogonal. This post is about the seams that made that possible.
The starting point
The minimal API is four methods:
1 | Put(key, value []byte) error |
Single store, no batching, no cancellation, no consistency primitives. Easy to write, easy to use.
The growth path matters. Each addition should be a new method, not
a new parameter on an old method. Adding a ctx context.Context to
Put would break every existing caller. Adding PutCtx(ctx, key, value) is a clean superset.
The current API surface
1 | // Basic |
Six concepts, all on the same store, none of which know about each other.
The seam: one internal write path
Every mutation in the system funnels into the same function:
1 | // internal |
Putis a degenerateBatchwith one op.PutWithTTLis the same with anExpireAtset.Deleteis a tombstone op.Batchis the user-visible version of the same struct.Txn.Commitis “validate, then submit aBatch“.- An async replication event becomes a
BatchviaApplyReplica. - A raft
Applybecomes aBatchvia the FSM.
This is the most important property of the design: there is exactly one code path that touches the WAL and the MemTable. New features add new producers in front of it; they do not add new durability code.
The seam: snapshots are read-only views
Snapshot doesn’t add a new read path. It captures (snapshot_seq, pinned_sstables) and decorates the existing read merge with a filter
“ignore entries with seq > snapshot_seq“.
1 | func (s *Snapshot) Get(k []byte) ([]byte, bool, error) { |
db.Get(k) is db.getAt(k, db.currentSeq(), db.currentSSTables()).
A snapshot just passes different arguments. No second implementation.
The same shape applies to iterators: a “live” iterator is exactly a
“snapshot iterator” with seq = currentSeq and pinned files held
until Close.
The seam: context-aware variants
Context cancellation in storage engines is awkward because most of the work is not cancellable: a 4 KiB block read takes microseconds, fsync takes milliseconds-to-seconds and can’t be aborted.
MiniKV’s Ctx variants check the context at boundaries:
1 | func (db *KV) PutCtx(ctx context.Context, k, v []byte) error { |
This is honest. We promise we won’t start work if the context is already cancelled. We don’t promise we can abort an fsync in progress.
For longer-running work (iterators, replication streams, snapshot sends) the context is checked between blocks/events, which is the natural cancellation point anyway.
The seam: Close is the universal release
Anything that pins resources implements Close():
Snapshot.Close()— decrement SSTable refcounts.Iterator.Close()— release per-SSTable readers + snapshot.Txn.Rollback()— drop write set, decrement snapshot.KV.Close()— drain workers, close WAL, sync.
There is no finalizer-based reclaim. If you don’t Close, you leak
SSTable files (deferred unlinks never fire) and eventually disk
space. The leaks are observable in KV.Stats() so debugging is
straightforward.
What this composability buys
A user can write, for instance:
1 | snap, _ := db.Snapshot() |
— a consistent scan that produces a delete batch — without any “scan-and-delete” API. Because:
- The iterator is read at a fixed snapshot, so concurrent writes don’t disturb the scan.
- The batch is written atomically, separately from the iteration.
- Nothing in
Snapshot/Iterator/Batch/Writehad to be designed with the others in mind. They compose because they share the internal seams above.
The lesson
Don’t grow your API by adding parameters to existing methods. Grow
it by adding new methods that share the implementation with the
old ones. The user-facing API can have ten variants of Put; the
internal write path should still be one function. The whole job of
the public API layer is to be the parameter-to-internal-call
translator.