Testing and Benchmarking a Storage Engine¶
Why storage engines need different tests¶
A storage engine's bugs rarely show up as a failing function call.
They show up as a value that disappears after the third compaction, a
deleted key that comes back after a crash, a corruption error on the
one-in-a-million torn write, or a deadlock between the commit and
background goroutines under load. Example-based unit tests miss these
because they check the cases the author thought of. This page covers
the techniques that found real bugs in this handbook's engine:
layered unit tests, a model-based test, crash simulation,
fuzzing, the race detector, and benchmarks that measure what
matters. Everything here runs with go test and nothing else.
Layer the tests in dependency order¶
When a high-level test fails, you want to know which layer is wrong. Test each layer against its own contract before testing the layers that use it:
| Layer | Test |
|---|---|
| Encoding (varints, internal keys, batch) | Round trip, malformed input → ErrCorrupt |
| WAL | Records of sizes 0, 1, 100, exactly one block, many blocks; truncation → ErrTornWrite; a flipped bit → ErrCorrupt |
| Block, table | 10,000-key round trip, lookups at start/middle/end, full iteration, bloom false-positive rate |
| Skip list, memtable | Ordering, newest-version lookups, concurrent readers |
Merging iterator, DBIter |
Versions, tombstones, snapshots |
| DB | Model test, crash test, concurrency test, transactions |
Model-based testing¶
Run thousands of random operations against the engine and against
a trivially correct model — a Go map — and compare them, repeatedly,
including across close and reopen:
func TestModel(t *testing.T) {
dir := t.TempDir()
opts := Options{MemTableSize: 64 << 10, TargetFileSize: 32 << 10} // tiny: force many flushes and compactions
db, _ := Open(dir, opts)
model := map[string]string{}
rng := rand.New(rand.NewPCG(1, 2)) // fixed seed: failures are reproducible
for phase := 0; phase < 6; phase++ {
for i := 0; i < 20000; i++ {
k := fmt.Sprintf("k%05d", rng.IntN(5000)) // small key space: many overwrites and deletes
if rng.IntN(5) == 0 {
db.Delete([]byte(k), WriteOptions{})
delete(model, k)
} else {
v := fmt.Sprintf("%s-%d-%d", k, phase, i)
db.Put([]byte(k), []byte(v), WriteOptions{})
model[k] = v
}
}
check(phase) // every model key via Get; full scan equals the sorted model
db.Close()
db, _ = Open(dir, opts) // recovery path
check(phase)
}
// No leaked files: every .sst on disk belongs to the current version.
}
Choices that make it effective:
- Tiny limits (64 KB memtables, 32 KB tables) so 120,000 operations trigger dozens of flushes and compactions — the code paths where bugs live.
- A small key space so the same keys are overwritten and deleted many times, exercising version shadowing and tombstone dropping.
- Reopen between phases so recovery is tested against the same model.
- Check both
Getand a full scan: they use different code (Table.Getwith the bloom filter vs. the merging iterator). - Check for leaked files — a reference-counting bug shows up as
.sstfiles that no version references. Check afterClose: the first version of this check ran while the DB was open and failed intermittently (8 live tables, 10 on disk) — but only without-race. Reopening had left 4 tables in L0, soOpenimmediately started a compaction, and the check saw its half-written outputs. The race detector slowed everything enough to hide the window. The same investigation found a real gap:Opendid not delete orphan tables left by a crashed flush or compaction; it does now, withTestOrphanTableDeleted. Run concurrency-heavy tests both with and without-race— different timing finds different bugs. - Fixed seeds, so a failure can be replayed exactly, and, in CI, a loop over many seeds.
Simulating crashes¶
Durability bugs only appear after a crash. A cheap, deterministic simulation: while the DB is running, copy its directory — as if the process died at that instant — damage the copy the way a crash would, and open it:
func TestCrashTornTail(t *testing.T) {
dir := t.TempDir()
db, _ := Open(dir, Options{})
for i := 0; i < 1000; i++ {
// Writes 0..899 are synced by the Sync on write 899; 900..999 are not.
db.Put(fmt.Appendf(nil, "k%04d", i), []byte("v"), WriteOptions{Sync: i == 899})
}
crash := t.TempDir()
for _, e := range readDir(dir) {
b := readFile(dir, e)
if filepath.Ext(e) == ".log" {
b = b[:len(b)-5] // the last record was torn mid-write
}
writeFile(crash, e, b)
}
db.Close()
db2, err := Open(crash, Options{})
// Every write before the torn one is recovered; the torn one is gone.
}
Extensions that a production engine needs:
- Drop unsynced data: copy only the bytes that were fsynced. This
requires a file-system interface that records sync points — Pebble's
vfs.NewStrictMem()does exactly this — and it is the test that catches "acknowledged before fsync" bugs (Write-Ahead Log). - Crash at every step: inject a failure at the Nth file operation, for every N, during a flush and a compaction, and check recovery each time.
- Kill -9 and power-cut tests in a VM for the final word (Files and Durability in Go).
Fuzzing the decoders¶
Every decoder in the engine — WAL records, blocks, table footers and handles, version edits — parses bytes from disk, which can be corrupted. Go's built-in fuzzer runs a function with mutated inputs, coverage-guided, and saves any input that panics:
func FuzzBlock(f *testing.F) {
var b blockBuilder
for _, k := range []string{"a", "ab", "abc", "b"} {
b.add(makeIKey(nil, []byte(k), 1, TypeValue), []byte("v"))
}
f.Add(b.finish(), []byte("ab")) // seed: one valid block
f.Fuzz(func(t *testing.T, block, target []byte) {
it := newBlockIter(block, bytes.Compare)
for it.First(); it.Valid(); it.Next() {
}
it = newBlockIter(block, bytes.Compare)
it.Seek(target) // must return or report corruption, never panic
})
}
go test -run='^$' -fuzz=FuzzBlock -fuzztime=60s ./kv
The engine already had round-trip tests for blocks and tables, and they all passed. The fuzzer found two crashing bugs in the block decoder, and fixing the first exposed a third by code review:
| Found by | Input | Bug | Fix |
|---|---|---|---|
| Fuzzer, in 0.3 s | A 1-byte block | newBlockIter recorded ErrCorrupt, but Seek ignored the error and indexed an empty restart array |
First/Seek return immediately if the iterator is already in error |
| Review while fixing it | A restart offset pointing past the data | decodeAt sliced data[off:] without checking off |
Bounds-check every restart offset |
| Fuzzer, after ~28 s | Huge unshared and value length varints |
rest < unshared+vlen — the sum overflowed uint64, wrapped to a small number, passed the check, and the slice expression panicked |
Compare each length separately: unshared > rest \|\| vlen > rest-unshared |
After the fixes, FuzzBlock ran 34.8 million inputs, FuzzLogReader
over 30 seconds, and a new FuzzOpenTable — which also covers footer
and block-handle validation, after readBlock gained a check that a
handle lies inside the file before allocating h.size bytes —
29.8 million inputs, all without a failure. The failing inputs are
saved under testdata/fuzz/ and rerun by plain go test forever
after, as regression tests.
The integer-overflow bug is worth remembering beyond this engine:
never validate untrusted lengths by adding them. a + b > limit
is wrong for attacker- or corruption-controlled a and b; a > limit
|| b > limit - a is right.
Concurrency testing¶
func TestConcurrent(t *testing.T) {
db, _ := Open(t.TempDir(), Options{MemTableSize: 32 << 10}) // frequent rotations
var wg sync.WaitGroup
for w := 0; w < 8; w++ {
wg.Add(2)
go writer(w) // 3,000 puts of key == value
go reader(w) // 3,000 random gets: a found value must equal its key
}
wg.Wait()
// then: every written key is readable
}
Run with go test -race. Small memtables make the commit goroutine
rotate while readers are pinning state and the background goroutine is
flushing — the interleavings where pointer handoffs go wrong. The race
detector checks every memory access in those interleavings for missing
happens-before edges (Concurrency Bugs);
the assertions check the logic. Neither is sufficient alone: the
protocol server's flush deadlock had no
data race at all and was only found by load.
Benchmarks that answer real questions¶
Measured on an Apple M1 Pro, 100-byte values:
| Question | Benchmark | Result |
|---|---|---|
| Raw write path cost | Put, Sync: false |
5.2 µs/op |
| Durable write latency | Put, Sync: true, 1 goroutine |
3.9 ms/op |
| Does group commit work? | Put, Sync: true, 128 goroutines |
76 µs/op — 51x better |
| Point read cost, cache mostly cold | Get, 200 K keys, 8 MB cache |
2.96 µs/op, 8% hit rate |
| Point read cost, cache warm | Get, 200 K keys, 64 MB cache |
1.09 µs/op, 99.5% hit rate |
| Write amplification | 1 M random writes, 3 key-space sizes | 2.2x → 3.7x → 6.6x |
Each row answers a question a user of the engine would ask, and each number came with an explanation: group commit amortizes fsync, the block cache hit rate dominates reads (Profiling with pprof), compaction cost grows with L1 size in a two-level design (Compaction and Recovery).
Benchmark design points for engines:
- Report domain metrics with
b.ReportMetric: hit rate, bytes written per user byte, stall time. A latency number without them can't be explained. - Separate setup from measurement: loading 200,000 keys is setup;
b.Loopexcludes it from the timing — but not from a CPU profile of the whole run, which you must keep in mind when reading the profile. - Measure under realistic sizes: a benchmark whose data fits in the block cache measures the cache.
- Steady state, not the first minute: LSM performance changes as
levels fill and compaction debt accumulates. A serious engine
benchmark (like RocksDB's
db_benchor Pebble'sycsbandnightlybenchmarks) runs long enough to reach steady state and reports throughput over time, not one number.
A CI recipe¶
go vet ./...
go test -race -count=1 ./... # all tests, race detector
go test -run='^$' -fuzz=FuzzBlock -fuzztime=60s ./kv # each fuzzer, briefly
go test -run='^$' -fuzz=FuzzLogReader -fuzztime=60s ./kv
go test -run='^$' -fuzz=FuzzOpenTable -fuzztime=60s ./kv
go test -run='^$' -bench=. -count=10 ./kv > new.txt && benchstat base.txt new.txt
Long fuzzing runs and multi-seed model tests belong in a nightly job; the saved corpus makes every PR rerun all previously found crashers in milliseconds.
Common pitfall¶
Trusting a test suite because it is green. Before fuzzing, this engine had passing tests for every layer, a model test over 120,000 random operations, crash tests, and race-detector runs — and input-dependent crashes in the block decoder that any corrupted block on disk would have triggered, turning a detectable corruption into a process crash. The model test could never find them because it only ever produced valid blocks. Tests check the inputs their author imagined; fuzzers and model-based tests explore inputs nobody imagined. A storage engine needs both, plus an explicit list of the untrusted inputs (everything read from disk or the network) and a fuzzer for each one.
Review questions¶
- Why does the model-based test use tiny memtables and a small key space?
- How does copying the data directory while the DB is running simulate a crash, and what can this simple version not catch?
- Explain the integer-overflow bug the fuzzer found. Why is
a + b > limitan unsafe check for untrusted lengths? - Why are the race detector and assertions on results both needed in the concurrency test?
- Which domain metrics would you report alongside ns/op for a storage engine benchmark, and why?