P04 — Log-Structured Storage Engine
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P04 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 99 hours · Weeks 35–43 · Stage 2 · Rust
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Do This Before You Start
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Persist an ordered key-value map on a device where sequential writes are far cheaper than random ones, supporting point reads, range scans, deletes, and crash recovery |
| 2. Constraints | Data exceeds RAM. Crash at any instruction. One thread of writes, many of reads |
| 3. Naive design | Yours. People invent: a mutable B-tree, a hash index over an append-only log, or a sorted file rewritten on every flush |
| 4. Predicted failure | Every naive design fails on one of the three amplifications. Predict which one yours pays and how much |
| 5. Minimal implementation | WAL + memtable + immutable SSTable + point read across all tables |
| 6. Correctness | Recovery equals the last acknowledged write; reads see the newest version of a key |
| 7. Instrumentation | Bytes written to disk vs bytes written by the user. Same for reads. Count them in the code |
| 8. Baseline | Your own MVI without Bloom filters or compaction |
| 9. Bottleneck | For a read-heavy Zipfian workload, is time in Bloom probes, index lookups, or block reads? |
| 10. Hypothesis | The compaction-strategy crossover as a function of read/write ratio. Predict the ratio at which they swap |
| 11. Modification | Implement the second compaction strategy |
| 12. Experiment | Both strategies × four workloads × three key distributions |
| 13. Failure analysis | Compaction debt, write stalls, and where the p99 went |
| 14. Report | The three-amplification frontier, measured, with the RUM trade made explicit |
Why This Project Matters
This is the project that makes you a systems engineer rather than an application engineer, and the reason is a single idea: you cannot optimise read amplification, write amplification, and space amplification simultaneously. Improving one degrades at least one other. This is the RUM conjecture, and reading it takes ninety seconds while feeling it takes nine weeks.
Once felt, it generalises everywhere. In P02 you traded recall for latency. In P03 you traded storage for recovery time. In P05 you will trade consistency for availability. In P14 you will trade precision for throughput. Every one of those is the same shape of argument, and this project is where the shape becomes obvious.
It is also, concretely, the engine underneath most of the infrastructure you already operate: RocksDB inside Kafka Streams and Flink, LevelDB's descendants inside DynamoDB and Cassandra, and the same segment-and-merge structure inside every Lucene index and therefore inside OpenSearch. You have been operating LSM trees for years.
Prerequisites
- P03's crash-test harness (reused directly)
- Rust: ownership,
Result, traits, iterators. If Rust is new, P11-I was your introduction and this is the first place it pays off - Understanding of disk behaviour: sequential vs random throughput on your actual device, measured — do that in milestone 1, not from memory
Duration and Size
Medium, 99 hours, 9 weeks. The largest Medium project in the journey.
| Tier | Contents | Hours |
|---|---|---|
| MVI | WAL, memtable, SSTable write, point read across tables, crash recovery. No compaction, no Bloom filters, no ranges. | 40 |
| Standard | + sparse index, Bloom filters, tombstones, range queries via a merging iterator, block checksums, both size-tiered and leveled compaction, a full workload generator. | 99 |
| Extension | Learned index blocks replacing the sparse index; or an adaptive compaction scheduler that responds to the measured read/write ratio. | +40–60 |
Central Technical Questions
- Why is an append-only design faster to write than an in-place one? Quantify it on your disk — the answer differs by 100× between spinning rust and NVMe.
- What does compaction actually buy, and what does it cost while it runs?
- What are the three amplifications for each strategy? Derive, then measure, then explain the gap.
- How much RAM does a Bloom filter save you, in disk reads, for absent keys?
- Why does p99 latency spike during compaction, and what are the mitigations?
- Why is a Zipfian workload fundamentally different from a uniform one for this structure? The answer is about which blocks stay in cache.
Architecture
Write your naive design first.
put(k,v) ─► WAL.append(k,v) ─► fsync? ─► memtable (skiplist / BTreeMap)
│ size > threshold
▼
immutable memtable ──flush──► SSTable (L0)
│
get(k) ──► memtable ──► immutable ──► L0 tables (newest first) ──► L1 ... Ln
│ │ │ │
└──────────────┴────────────┴── Bloom filter per table ─┘
sparse index per table
block cache
SSTable layout:
┌──────────────┬──────────────┬─────────────┬──────────────┬────────┐
│ data blocks │ bloom filter │ sparse index│ footer(offsets)│ CRC32C │
│ (4-64 KB, │ (10 bits/key)│ (1 entry │ │ │
│ sorted, ea. │ │ per block) │ │ │
│ CRC'd) │ │ │ │ │
└──────────────┴──────────────┴─────────────┴──────────────┴────────┘
The three amplifications, derived
Let \(T\) be the level size ratio (fanout, conventionally 10) and \(L\) the number of levels.
Leveled compaction. Each level holds non-overlapping runs and is \(T\)× the size of the one above. Merging one level into the next rewrites roughly \(T\) bytes of the target for every byte of source, so:
- write amplification ≈ \(T \cdot L + 1\) (the +1 is the memtable flush)
- read amplification ≈ \(L + 1\) tables consulted per point read (before Bloom)
- space amplification ≈ \(1 + 1/T \approx 1.1\) — at most one obsolete copy per key
Size-tiered compaction. Runs of similar size accumulate and are merged together, so each byte is rewritten roughly once per level:
- write amplification ≈ \(L + 1\)
- read amplification ≈ \(T \cdot L\) runs, since each level holds up to \(T\) of them
- space amplification ≈ 2× or worse, since \(T\) copies of a key can coexist and compaction needs free space equal to the inputs
Computed for \(T = 10\), 64 MB base level:
| data | levels | leveled W / R / S | size-tiered W / R / S |
|---|---|---|---|
| 1 GB | 2 | 21 / 3 / 1.10 | 3 / 20 / 2.11 |
| 8 GB | 3 | 31 / 4 / 1.10 | 4 / 30 / 2.11 |
| 64 GB | 3 | 31 / 4 / 1.10 | 4 / 30 / 2.11 |
| 512 GB | 4 | 41 / 5 / 1.10 | 5 / 40 / 2.11 |
Read that table as a choice, not a ranking. Leveled writes each byte ~31 times to keep reads at 4 tables and space at 1.1×. Size-tiered writes each byte ~4 times and pays with 30 tables per read and 2.1× the disk. On write-heavy ingest, size-tiered is 8× cheaper in device wear; on read-heavy serving, leveled is 7× cheaper in seeks. There is no third option that wins both, and that is the RUM conjecture with numbers attached.
What Bloom filters do to the read path
A point read for an absent key must consult every run. With 40 runs that is 40 random reads to answer "no". A Bloom filter per run turns most into an in-memory rejection.
The optimal configuration for \(m\) bits over \(n\) keys is
\(k = (m/n)\ln 2\) hashes giving \(\text{fpr} = 0.6185^{m/n}\)
(full derivation and a working implementation in tools/bloom.py).
Measured against theory, 100k keys, 200k absent probes:
| bits/key | k | fill ratio | theory | measured | RAM |
|---|---|---|---|---|---|
| 4 | 3 | 0.5275 | 0.14689 | 0.14709 | 0.05 MB |
| 8 | 6 | 0.5281 | 0.02158 | 0.02204 | 0.10 MB |
| 10 | 7 | 0.5042 | 0.00819 | 0.00822 | 0.12 MB |
| 16 | 11 | 0.4973 | 0.00046 | 0.00047 | 0.20 MB |
Theory matches measurement within 5%. The fill ratio sits at 0.5 at every optimum — that is the entropy argument showing up in the data.
Translated to the read path with 40 runs on disk:
| bits/key | fpr | disk reads for an absent key | improvement |
|---|---|---|---|
| no filter | 1.0 | 40.0 | 1× |
| 4 | 0.147 | 5.88 | 7× |
| 10 | 0.00819 | 0.328 | 122× |
| 16 | 0.00046 | 0.018 | 2179× |
125 KB of RAM per 100k keys turns 40 disk reads into 0.33. That is why 10 bits/key is the near-universal default, and now you can derive it rather than cite it.
One measurement caution the tool also demonstrates: at 24 bits/key the predicted fpr is ~10⁻⁵, so 200k probes expect ~1.2 false positives. Observing 0 or 3 is Poisson noise, not a result. To measure a rate \(p\) you need ~\(100/p\) trials for a 10% relative standard error. State the resolution of your experiment before you report a ratio.
Showcase — Do This Before You Start
W2 · walkthroughs/w2_lsm.py · ~60 minutes
A working miniature of this project: forty runs, a Bloom filter each, and the measurement that contradicts "Bloom filters only help misses".
cd walkthroughs && python3 w2_lsm.py
It is 80-ish lines and it surfaces this project's central surprise in an evening rather than in week six. Run it before committing the weeks.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Repo; measure your disk: sequential vs random, read vs write, various block sizes | 6 | You have your device's numbers, not folklore |
| 2 | WAL: record format, CRC, append, replay, torn-tail handling | 8 | Truncated tail replays cleanly |
| 3 | Memtable (BTreeMap or a hand-written skiplist) + size accounting | 6 | Flush triggers at the right byte count, not entry count |
| 4 | SSTable writer: sorted blocks, block CRCs, footer | 8 | Format documented before implementation |
| 5 | SSTable reader: binary search over the sparse index, block cache | 8 | Point read works across memtable + N tables |
| 6 | Bloom filter (yours, not a crate) + the theory-vs-measured table | 6 | Your measured fpr matches theory within 10% |
| 7 | Tombstones + delete semantics + the newest-version-wins rule | 5 | Deleted keys stay deleted across flush and compaction |
| 8 | Merging iterator + range queries | 8 | Range scan across all levels returns each key once, newest version |
| 9 | Size-tiered compaction | 10 | Runs continuously; amplification counters live |
| 10 | Leveled compaction | 12 | Same, with non-overlapping level invariant asserted |
| 11 | Workload generator: uniform / Zipfian / sequential, read/write mixes | 8 | Reproducible with a seed |
| 12 | Crash recovery hardening + the P03 kill harness | 8 | ≥50 random kill points, zero acknowledged-write loss |
| 13 | Experiments + report | 6 | All rows filled |
Concepts To Study
- Sequential vs random I/O, on SSD and on NVMe specifically — including why the gap is smaller than the folklore and why it still matters (write amplification inside the FTL)
- Write-ahead logging, group commit, and the fsync-per-write cost
- Memtables: skiplists vs balanced trees; why skiplists are the traditional choice
(lock-free insertion) and why
BTreeMapis fine here - SSTable format design: block size as a read-amplification/space trade
- Sparse indexes: one index entry per block, not per key, and the memory this saves
- Bloom filters: the derivation above; also why they cannot support range queries, and what a prefix Bloom filter is
- Compaction strategies: size-tiered, leveled, FIFO, and the hybrid RocksDB actually ships
- The three amplifications and the RUM conjecture
- Tombstones and the delete-then-compact problem: why a range delete is worse
- Block cache and how it interacts with the page cache (double caching)
- Checksums: CRC32C, hardware acceleration, per-block granularity
- Write stalls and backpressure: what happens when compaction cannot keep up
Primary-Source Readings
Budget: 15 hours — the largest reading budget in the journey, because this literature is unusually good.
| Reading | Why | Hours |
|---|---|---|
| O'Neil, P. et al. The Log-Structured Merge-Tree (LSM-Tree). Acta Informatica 33, 1996 | The origin. The cost model in §3 is the derivation above | 3 |
| Ghemawat, S., Dean, J. LevelDB implementation notes and source | The clearest small LSM. Read the code after milestone 5 | 2 |
| Dong, S. et al. Optimizing Space Amplification in RocksDB. CIDR 2017 | Real production numbers for the trade you are measuring | 2 |
| Athanassoulis, M. et al. Designing Access Methods: The RUM Conjecture. EDBT 2016 | The framing that makes the whole project one idea | 1.5 |
| Dayan, N., Athanassoulis, M., Idreos, S. Monkey: Optimal Navigable Key-Value Store. SIGMOD 2017 | Bloom bits should NOT be uniform across levels. A genuinely surprising result | 2 |
| Rosenblum, M., Ousterhout, J. The Design and Implementation of a Log-Structured File System. SOSP 1991 | Where log-structuring came from; the cleaning-cost analysis is the compaction analysis | 2 |
| Chang, F. et al. Bigtable. OSDI 2006 | SSTables in their original context | 1.5 |
| Bloom, B. H. Space/time trade-offs in hash coding with allowable errors. CACM 13(7), 1970 | Three pages. Read the original | 0.5 |
| Pillai, T. S. et al. All File Systems Are Not Created Equal. OSDI 2014 | What your fsync discipline actually guarantees | 0.5 |
Monkey is the best paper on this list and the one most likely to give you a hypothesis worth testing: it shows the optimal Bloom allocation gives more bits to smaller levels, because a level's contribution to false-positive cost is independent of its size while its memory cost is not. Your E7 tests it.
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Workload mix | 100/0, 90/10, 50/50, 10/90 read/write | Where do the two compaction strategies cross? |
| E2 | Key distribution | uniform / Zipfian(α=1.0) / sequential | Zipfian should be much faster. Why, mechanically? |
| E3 | Compaction strategy | size-tiered vs leveled × E1 × E2 | The full matrix; this is the project's core result |
| E4 | The three amplifications | measured across E3 | Do your measurements match the derived table? Explain the gap |
| E5 | Bloom bits/key | {0,4,8,10,16} | Read amp for absent keys; compare to the derived table |
| E6 | Bloom false-positive rate | measured vs theory | And state your measurement resolution |
| E7 | Monkey allocation | uniform bits vs level-optimised | Predict the memory saving at equal fpr |
| E8 | Block size | {4,16,64,256} KB | Read amp vs space; the crossover depends on value size |
| E9 | Memtable size | {4,16,64,256} MB | Write amp vs recovery time — a direct trade |
| E10 | fsync policy | never / group commit / every write | Throughput ratio; predict the order of magnitude |
| E11 | Crash recovery time | vs WAL length | Linear; state the constant |
| E12 | p99 during compaction | vs steady state | The number that matters in production |
| E13 | Write stalls | ingest faster than compaction can keep up | Where does it break, and how does it fail? |
| E14 | Cache behaviour | block cache size {0..RAM}, Zipfian | Predict the hit-rate curve shape |
E3 is the deliverable. A single figure with read/write ratio on the x-axis, cost on the y-axis, one line per strategy, and the crossover marked — that plot is a portfolio artifact.
E13 is the one people skip and shouldn't. An LSM engine under sustained overload does not degrade gracefully by default; it accumulates compaction debt until reads collapse. Finding your engine's breaking point, and what it does when it breaks, is more valuable than another 10% on throughput.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| Write throughput (ops/s and MB/s) | With the durability setting stated |
| Read throughput, point and range | Separately; range is a different access pattern entirely |
| p50/p95/p99/p99.9 | p99.9 matters here because compaction is a rare, large event |
| Write amplification | bytes written to device / bytes written by user. Count in code |
| Read amplification | blocks read / blocks logically needed |
| Space amplification | bytes on device / bytes of live data |
| Bloom fpr, measured | With the number of probes stated |
| Compaction throughput and debt | MB/s merged; bytes pending |
| Recovery time | vs WAL length |
| Block cache hit rate | By workload |
| Stall time | Seconds of blocked writes per hour of ingest |
Instrument amplification with counters inside the code, not by watching iostat.
You need to attribute bytes to a cause, and the OS cannot do that for you.
Correctness Tests
- Model-based testing. Run every operation against both your engine and a
BTreeMap; assert identical results after every op. The single highest-value test here — write it at milestone 3. - Crash consistency at ≥50 randomised kill points: every acknowledged write present, no unacknowledged write present.
- Newest version wins across memtable, L0, and all levels.
- Tombstones survive compaction until the bottom level.
- Range scan completeness: every live key exactly once, in order, newest version.
- Level invariant (leveled): no overlapping key ranges within a level ≥ 1. Assert after every compaction.
- Bloom filters never produce false negatives. Assert on every inserted key.
- Block CRC detects a single flipped bit in every block type.
- Idempotent recovery: recover twice, identical state.
- Compaction preserves semantics: full-scan equality before and after.
- Fuzz: random operation sequences with random crashes, compared against the model.
Failure Tests
| Injection | Predicted symptom | Lesson |
|---|---|---|
kill -9 mid-WAL-append | Torn tail; replay stops at last valid CRC | Torn tails are normal |
kill -9 mid-flush | Partial SSTable; discarded on recovery | Atomicity by rename |
kill -9 mid-compaction | Inputs and partial output coexist | Compaction needs its own crash-atomicity |
| Disk full during compaction | Clean failure, engine still readable | ENOSPC during compaction is the classic outage |
| Corrupt one data block | Detected; error names the block | Not silent wrong answers |
| Corrupt the footer | Table rejected entirely | Metadata corruption must fail loudly |
| fsync fails (EIO) | Must not report success | The Postgres fsync-gate lesson |
| Clock jump | No effect — never order by wall clock | Sequence numbers, not timestamps |
| Ingest at 10× compaction throughput | Write stalls, bounded memory | E13 |
| Delete 90% of keys, then range-scan | Tombstone scan cost is visible | Why range deletes are a known pathology |
Expected Difficulties
- Compaction is where the bugs live, and they are concurrency bugs with a file system attached. Mitigation: single-threaded compaction first, and the model-based test running throughout.
- Amplification instrumentation has to be designed in. Retrofitting byte counters after the fact means missing paths. Do it in milestone 2.
- Rust plus a new domain is two hard things. Mitigation: P11-I gave you the
language; keep the data structures boring (
BTreeMap,Vec<u8>) and spend your difficulty budget on the storage logic. - "Just use
BTreeMapfor the memtable" feels like cheating. It is not. The memtable is not the mechanism under study. - Benchmarks on a laptop with an active page cache lie. Your working set must exceed RAM, or you are benchmarking the page cache. State your working-set-to-RAM ratio on every result.
- The 9-week ceiling is real. If leveled compaction is unfinished at week 9, ship size-tiered, state the limitation, and move on.
Scope Boundaries
In scope: single-node, single-column-family, byte-string keys and values, point reads, range scans, deletes, crash recovery, two compaction strategies.
Out of scope: transactions, MVCC snapshots (P03 has your snapshot experience), column families, secondary indexes, a network layer, replication (P05), a SQL layer, compression (mention in the report; do not implement), and multi-threaded compaction until the extension.
Deliverables
lsmdb/— embeddable Rust crate with a CLI and a benchmark runnerFORMAT.md— byte-level SSTable and WAL layoutREPORT.mdcentred on the E3 crossover figure and the E4 amplification comparisonworkloads/— the generator, reusable in P05- Notebook entries for E3, E7, E12, E13
- The amplification-counter instrumentation as a reusable pattern
Exit Criteria
- Model-based test passes over ≥10⁶ random operations
- Crash test passes at ≥50 randomised kill points
- Both compaction strategies implemented and running
- E3 complete: the crossover figure exists, with the crossing ratio stated
- E4 complete: all three amplifications measured for both strategies, compared against the derived table, gaps explained
- Bloom fpr measured against theory with the measurement resolution stated
- p99 during compaction reported alongside steady state
- E13 run: the engine's overload behaviour characterised
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Monkey-style Bloom allocation measured against uniform. Small, sharp, real.
- Learned index blocks replacing the sparse index (Kraska et al.). Measure lookup time and memory; be honest that the win is usually small and workload-dependent.
- Adaptive compaction: switch strategy based on the observed read/write ratio. A research direction.
- Prefix Bloom filters for range queries with a common prefix.
- Multi-threaded compaction with a measured scaling curve and a stall analysis.
Connections
Backward: P03 gives you the crash-test harness and the recovery-time measurement that motivated this whole project. P11-I gave you Rust.
Forward:
- → P05 (Distributed KV): this engine is the per-node storage. Its WAL becomes the replicated log's local persistence; its snapshots become Raft snapshots.
- → P07 (Streaming): stateful operator state is an LSM in every real system (RocksDB in Flink and Kafka Streams). Your checkpointing reuses this.
- → P12 (Kernel): page cache, I/O scheduling, and fsync semantics become things you implement rather than call.
- → P15: interaction/item storage.
References
- O'Neil, P., Cheng, E., Gawlick, D., O'Neil, E. The Log-Structured Merge-Tree (LSM-Tree). Acta Informatica 33(4), 1996.
- Rosenblum, M., Ousterhout, J. K. The Design and Implementation of a Log-Structured File System. SOSP 1991.
- Chang, F. et al. Bigtable: A Distributed Storage System for Structured Data. OSDI 2006.
- Athanassoulis, M., Kester, M. S., Maas, L. M., Stoica, R., Idreos, S., Ailamaki, A., Callaghan, M. Designing Access Methods: The RUM Conjecture. EDBT 2016.
- Dayan, N., Athanassoulis, M., Idreos, S. Monkey: Optimal Navigable Key-Value Store. SIGMOD 2017.
- Dong, S., Callaghan, M., Galanis, L., Borthakur, D., Savor, T., Strum, M. Optimizing Space Amplification in RocksDB. CIDR 2017.
- Bloom, B. H. Space/time trade-offs in hash coding with allowable errors. CACM 13(7), 1970.
- Kirsch, A., Mitzenmacher, M. Less Hashing, Same Performance: Building a Better Bloom
Filter. ESA 2006. The double-hashing trick used in
tools/bloom.py. - Kraska, T., Beutel, A., Chi, E. H., Dean, J., Polyzotis, N. The Case for Learned Index Structures. SIGMOD 2018.
- Pillai, T. S. et al. All File Systems Are Not Created Equal. OSDI 2014.
- Ghemawat, S., Dean, J. LevelDB. github.com/google/leveldb — read
db_impl.ccandversion_set.ccafter milestone 9.