π Local build β commit and timestamp are injected automatically on Cloudflare Pages.
Preface β Why This Book Exists
- How This Book Is Structured
- Who This Book Is For
- A Note on Style
- FoundationDB Layers β Go Implementations
βIn many of the more relaxed civilizations on the Outer Eastern Rim of the Galaxy, the Hitchhikerβs Guide has already supplanted the great Encyclopaedia Galactica as the standard repository of all knowledge and wisdom.β β Douglas Adams
This book aims to be that for FoundationDB: the practical, opinionated, source-level guide you wished existed when you first opened the docs.
There is no shortage of FoundationDB material on the internet. There is a tidy official documentation site, a handful of conference talks, a research paper (SIGMOD 2021), and a famous CMU Database Group talk by Markus Pilman. Each piece is good. None of them together is enough to build a layer.
Whatβs missing is the connective tissue:
- The history of how FDB got here β why the original storage engine was
literally SQLiteβs B-tree (
KeyValueStoreSQLite), why that was replaced by Redwood, and what Sharded RocksDB is doing in the codebase as of 7.3. - The source-level mechanics β what a commit actually does on the wire, how
many
fsynccalls it costs, what the Resolverβs in-memory data structure looks like, how Flowβs actor model compiles down to C++. - The numbers β concrete latency budgets, throughput ceilings, transaction volume curves, and what changes them. Not βFDB is fastβ but βa 3-node cluster on c5.4xlarge sustains β55,000 cross-shard commits/sec at p99 < 8 ms, here is the workload, here is how to reproduce it.β
- The patterns β five hands-on labs in this repo that turn that theory into Go code you can run, modify, and break.
This book is what happens when you treat FoundationDB the way Brendan Gregg treats Linux performance, or the way Designing Data-Intensive Applications treats distributed systems: with curiosity, citations, and the willingness to follow a function call across a process boundary into another machine.
If you finish this book you should be able to:
- Explain to a colleague exactly what happens during
db.Transact(...)β every network hop, every disk write, every B-tree page touched. - Read the FoundationDB source tree (
fdbserver/,fdbclient/,fdbrpc/,flow/) and know which files to open for a given question. - Design a new layer (graph, vector index, time series, queue) from scratch, choosing the right key encoding, conflict-range strategy, and atomic op primitives.
- Reproduce the published benchmarks and reason about why your workload deviates from them.
- Submit a meaningful PR to apple/foundationdb β fix a bug, add a test, improve a doc, optimize a hot path. The contributing chapter is a real path, not a token gesture.
How This Book Is Structured
The book has two parts. Part I β Background is a deep, source-level guide to FoundationDB itself. Read it in order; each chapter is a prerequisite for the next. Part II β Labs is five independent Go implementations you can run; pick any order, but each labβs walk-through assumes you know Part I.
| Part I β Background | Why |
|---|---|
| 1. The Storage Stack | The vocabulary: B-tree vs LSM, WAL, buffer pool, RUM trade-off. |
| 2. FoundationDB in Depth | Cluster roles, commit pipeline, MVCC, watches, atomic ops. |
| 3. Storage Engines: SQLite β Redwood β Sharded RocksDB | How the on-disk format evolved and why. Source-level. |
| 4. Flow, Actors, and Simulation | The C++ extension that makes FDB possible. Worked source examples. |
| 5. Performance β Latency, Throughput, Concurrency in Numbers | Hard numbers, reproducible workloads, capacity planning. |
| 6. The Layer Concept | Key encoding, subspaces, atomicity, conflict ranges. |
| 7. How Real Systems Use FDB | CloudKit, Snowflake, Wavefront, Document Layer, TiKV, mvsqlite. |
| 8. This Repository | Map of the labs and how to navigate them. |
| 9. Reading Guide | Papers, talks, books, and source paths to read next. |
| 10. Contributing to FoundationDB | Building the source, first-PR ideas, where the maintainers hang out. |
| Part II β Labs | Plugs in | Teaches |
|---|---|---|
| Option A β LevelDB API over FDB | Above LevelDB | External KV API, iterators, snapshots, batches |
| Option A β SQL Layer over FDB | Above SQLite | How SQL decomposes into storage ops |
| Option B β LevelDB on FDB Storage | Below LevelDB | LevelDB internals: SSTs, WAL, MANIFEST |
| Option B β SQLite VFS on FDB | Below SQLite | SQLite internals: page model, VFS, journaling |
| Option C β Record Layer over FDB | Directly on FDB | Native FDB layer: records + secondary indexes |
Who This Book Is For
- Backend engineers who use FDB-backed services (CloudKit, Snowflake metadata, Tigris, etc.) and want to understand the substrate.
- Database engineers designing a new storage system or evaluating FDB.
- Distributed systems students who have read DDIA and want a single open-source system to study end-to-end.
- Interview candidates preparing for senior systems roles β the βInterview Questionsβ sections at the end of each chapter are designed for exactly this.
- Aspiring FDB contributors β the final chapter is your runway.
A Note on Style
This book quotes the FoundationDB source tree liberally. All citations are to apple/foundationdb at the time of writing (release-7.3 branch unless noted). Where source files have moved between releases, I give the older path too. Code excerpts are short and used only for explanation; the projectβs Apache 2.0 license permits this, and the original copyright stays with the FoundationDB authors.
Where I cite latency or throughput numbers, I cite the hardware and workload. Treat every number as falsifiable; the reproduction recipe is always nearby.
Now β donβt panic β and turn the page.
FoundationDB Layers β Go Implementations
Five self-contained Go implementations that show different ways to build a βlayerβ on top of FoundationDB. Modeled after real projects (mvsqlite, fdb-record-layer) but pared down for clarity.
Layout
| Folder | Plugs in | Teaches |
|---|---|---|
| option-a-leveldb | Above LevelDB | LevelDBβs external API: iterators, snapshots, write batches |
| option-a-sqlite | Above SQLite | How SQL decomposes into storage ops; vtab query planning |
| option-b-leveldb | Below LevelDB | LevelDB internals: SST files, WAL, MANIFEST, CURRENT |
| option-b-sqlite | Below SQLite | SQLite internals: page model, VFS, journaling |
| option-c-record-layer | Directly on FDB | Native FDB layer: records + secondary indexes |
Prerequisites
- Docker β to run a local single-node FDB cluster
- FoundationDB client library on the host (
libfdb_c) β required by the Go bindings (CGO)- macOS:
brew install foundationdb(or install the official.pkgfrom Appleβs release page)
- macOS:
- Go 1.22+
Bootstrap the cluster
docker compose up -d
./scripts/bootstrap-fdb.sh
This creates ./fdb.cluster at the repo root. Every demo reads it via the relative path ../fdb.cluster.
To shut down: docker compose down (data persists in ./fdb-data).
To wipe everything: docker compose down -v && rm -rf fdb-data fdb-config fdb.cluster.
Running a demo
Each option is an independent Go module:
cd option-a-leveldb
go run ./demo
See each folderβs docs/ for an architecture walk-through.
Documentation (mdbook)
The docs are structured as an mdbook under book/. To read them locally:
# Install mdbook (once)
brew install mdbook
# Build the book (output β book/dist/)
mdbook build
# Or serve with live-reload
mdbook serve --port 3001
The book is organized into two parts:
- Background β The Hitchhikerβs Guide split into six chapters (storage stack, FDB internals, the layer concept, real-world systems, repo overview, further reading)
- Labs β One overview + architecture walk-through per option
The Hitchhikerβs Guide to FoundationDB and Storage Layers
βDonβt Panic.β
This guide assumes you have seen a keyβvalue store before but have never thought hard about what happens underneath. By the end you should be able to design and build your own layer on top of FoundationDB β not just copy one.
What This Guide Covers
Ten chapters that take you from first principles to source-level fluency. Read in order; each chapter is a prerequisite for the next.
| Chapter | Topic | What youβll know after |
|---|---|---|
| 1. The Storage Stack | B-trees, LSM-trees, WAL, buffer pools | Why every database makes the same five trade-offs |
| 2. FoundationDB in Depth | MVCC, commit pipeline, cluster roles, watches, atomic ops | How FDB achieves correctness end-to-end |
| 3. Storage Engines | SQLite β Redwood β Sharded RocksDB, source-level | What is actually written to disk, and why it changed |
| 4. Flow, Actors, and Simulation | The C++ extension, the event loop, deterministic testing | How to read any .actor.cpp file in the source tree |
| 5. Performance | Latency, throughput, concurrency in concrete numbers | How to size and predict an FDB clusterβs behavior |
| 6. The Layer Concept | Key encoding, subspace pattern, conflict ranges | How to turn an ordered KV store into any data model |
| 7. The Record Layer β A Deep Dive | Appleβs open-source Record Layer, source-level | How CloudKit-grade record storage is built on FDB |
| 8. How Real Systems Use FDB | CloudKit, mvsqlite, Document Layer, TiKV, Snowflake | That the patterns in this repo are battle-tested in production |
| 9. This Repository | The five lab implementations | How to navigate, run, and extend the labs |
| 10. Reading Guide | Papers, books, source code to read next | Where to go to become a true expert |
| 11. Contributing to FoundationDB | Building from source, first-PR catalog | A real path to becoming an upstream contributor |
How to Read This
If you have 2 hours: Read chapters 1β3 (the storage stack, FDB in depth, and the storage-engine deep dive), then pick any one lab and read its Architecture Walk-through.
If you have a day: Read all ten chapters in order, then work through all five labs (A-leveldb β A-sqlite β B-leveldb β B-sqlite β C-record-layer). The labs increase in conceptual complexity.
If youβre preparing for a systems interview: Focus on chapters 1, 2, 5 (performance), and 6 (the layer concept), and the Interview Q&A sections at the end of each chapter.
If you want to contribute upstream: Read chapters 2, 3, and 4 carefully, then jump to Chapter 11 β Contributing for the runway.
If you just want to run the code: Jump straight to Chapter 9 β This Repository, then come back to the background chapters for depth.
Core Insight
Every database is a stack of layers, each one a client of the one below. FoundationDB occupies one specific layer: ordered, transactional, distributed key-value storage. Every layer above it β SQL, document model, LevelDB API, SQLite page store, record layer β is a pure encoding problem. Learn the encoding and you understand the database.
That is what this guide teaches.
The Storage Stack β Where Everything Lives
- The Canonical Stack
- B-Trees vs. Log-Structured Merge Trees
- Write-Ahead Log (WAL) β Crash Recovery Mechanics
- Buffer Pool Management
- Where Each Lab Sits in the Stack
- Interview Questions
Every database you have ever used is a stack of layers, each hiding detail from the layer above. Knowing exactly where FoundationDB sits in that stack β and why that position is powerful β is the foundation of everything else in this guide.
The Canonical Stack
βββββββββββββββββββββββββββββββββββββββββββββββ
β Application (SQL, gRPC, REST API) β β you usually live here
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Query Planner / Optimizer β β rewrites queries, picks indexes
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Execution Engine (joins, aggregates) β β evaluates the query plan
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Storage Engine (rows, columns, indexes) β β where FDB plays
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Page / Buffer Cache β β in-memory hot pages
βββββββββββββββββββββββββββββββββββββββββββββββ€
β File / Block I/O (VFS, pread/pwrite) β β option-b-sqlite plugs in here
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Kernel: page cache, scheduler, filesystem β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Physical: SSD / NVMe / network storage β
βββββββββββββββββββββββββββββββββββββββββββββββ
The interesting question is: which layer do you own, and what contract does it expose downward?
Most database literature focuses on the top (SQL, query plans). Most OS literature focuses on the bottom (filesystems, block I/O). The storage engine layer β the middle β is where the real trade-offs live: ordering, atomicity, durability, and concurrency.
FoundationDB is a storage engine that exposes a remarkably clean API to everything above it. This repository explores five different ways to build on top of that API.
B-Trees vs. Log-Structured Merge Trees
Every practical storage engine is built on one of two foundational data structures. Understanding both is essential for reasoning about FDBβs design and the designs of the engines it replaces.
B-Trees
A B-tree stores data as a balanced tree of fixed-size pages (usually 4β16 KB). Updates happen in-place: to change a value, you find its page, overwrite it, and write it back to disk.
ββββββββββββββββ
β [20] [50] β β interior node (keys only)
ββββββββββββββββ
/ | \
ββββββββββββ ββββββββββββ ββββββββββββ
β [5][12] β β[25][35] β β[55][80] β β leaf nodes (key+value)
ββββββββββββ ββββββββββββ ββββββββββββ
Properties:
- Read amplification: O(log N) page reads to find a key (very good)
- Write amplification: ~1β3Γ (one write per page touched, but journaling doubles it)
- Space amplification: ~33% overhead on average (partially filled pages)
- Random writes: each update may touch a different page β high random I/O on spinning disks
Why B-trees dominated for 40 years: HDDs were sequential-read fast, random-read slow. B-trees minimize the number of seeks to reach a key. SSDs changed this equation β random reads became cheap.
Used by: SQLite, PostgreSQL, MySQL InnoDB, Oracle, early MongoDB (MMAPv1)
Log-Structured Merge Trees (LSM Trees)
LevelDB, RocksDB, and Cassandraβs storage engine are all LSM trees. The core idea: never overwrite; always append.
Write path:
incoming write
β
WAL (append-only log on disk β for durability)
β
MemTable (skip list in memory β for fast writes)
β (when MemTable is full, flush to disk)
Level 0 SSTables (sorted, immutable files)
β (background compaction)
Level 1, 2, ... SSTables
Anatomy of an SSTable (Sorted String Table):
βββββββββββββββββββββββββββββββββββββββββββββββ
β Block 0: appleβ1, bananaβ2, cherryβ3 ... β
β Block 1: dateβ4, elderberryβ5, figβ6 ... β
β ... β
β Index block: [appleβoffset0, dateβoffset1] β
β Bloom filter: membership query, ~1% FP rate β
β Footer: offsets to index+filter blocks β
βββββββββββββββββββββββββββββββββββββββββββββββ
Properties:
- Write amplification: high (10β30Γ in a 7-level LSM; data is rewritten during each levelβs compaction)
- Read amplification: moderate (must check all levels for a key; bloom filters help)
- Space amplification: moderate (old versions linger until compaction)
- Sequential writes: almost entirely sequential β excellent on SSDs and HDDs alike
Why LSM trees dominate at scale today: write throughput is often the bottleneck at scale, and LSM trees turn random writes into sequential appends. RocksDB (Facebook) and Cassandra (Apache) both validate this at planetary scale.
The RUM Conjecture
The RUM conjecture (Idreos et al., 2016) formalizes the fundamental trade-off:
You cannot minimize Read cost, Update cost, and Memory/space cost simultaneously. Optimizing two always worsens the third.
| Engine | Read (R) | Update (U) | Memory (M) | Best for |
|---|---|---|---|---|
| B-tree | Low | Medium | Medium | Read-heavy OLTP |
| LSM tree | Medium | Low | High | Write-heavy OLTP |
| Hash table | Very low | Medium | High | Point lookups only |
| Column store | Low | High | Low | Analytical queries (OLAP) |
| FDB (B-tree) | Low | Medium | Medium | Consistent distributed KV |
FDB uses a custom B-tree variant internally on each Storage Server. The distributed nature adds replication overhead but also provides horizontal read scaling.
Write-Ahead Log (WAL) β Crash Recovery Mechanics
Every durable storage engine uses a WAL. Understanding WAL mechanics is essential for understanding what FDB guarantees and what xSync (in the SQLite VFS) must do.
The problem: writing a page to disk is not atomic. A 4 KB page write may partially complete before a power failure. Result: corrupted page.
WAL solution:
1. Before modifying Page X, write the INTENT to the WAL:
WAL record: {LSN: 1042, page: X, before: <old bytes>, after: <new bytes>}
2. fsync() the WAL record to disk (durability guarantee)
3. Now apply the change to Page X in the buffer cache
4. Page X is written back to disk lazily (the WAL already protects us)
On crash recovery:
- Re-apply all WAL records since the last checkpoint β consistent state
- If a WAL record is incomplete (partially written) β discard it, prior state is clean
FDBβs relationship with WAL:
FDBβs Transaction Log layer IS the WAL. When a commit is acknowledged, the write is persisted in the Transaction Log on f+1 machines (where f is the fault tolerance factor). Storage Servers apply these writes asynchronously. This is why FDBβs commit is durable even if Storage Servers crash before applying the write β the Transaction Log holds the record.
This also explains why our SQLite VFS (option-b-sqlite) makes xSync a no-op: by the time Transact() returns, FDB has already done the equivalent of fsync on the WAL.
Buffer Pool Management
In any storage engine, the buffer pool (or page cache) is the in-memory cache of disk pages. It is often the single most important performance variable.
Key metrics:
- Hit ratio: fraction of page reads served from cache (aim for >99%)
- Eviction policy: LRU (Least Recently Used), LFU (Least Frequently Used), CLOCK
- Write-back vs. write-through: most use write-back (dirty pages flushed lazily to amortize I/O)
Why this matters for FDB:
FDBβs clients do not manage a buffer pool. Each Storage Server manages its own buffer pool internally. When you call rt.Get(key), the FDB client sends a network request; the Storage Server consults its buffer pool and returns the value. You have no visibility into whether this was a cache hit or a disk read.
For read-heavy workloads, adding more Storage Servers (and thus more total buffer pool capacity) linearly increases the read cache size.
Where Each Lab Sits in the Stack
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application code (demo/main.go) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β option-a-leveldb β option-a-sqlite β option-c-record β
β (LevelDB API) β (SQL engine) β (Record + Index) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β FoundationDB client library (fdb.Transact / ReadTransact) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β FDB cluster (coordinators, proxies, storage) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β option-b-sqlite β option-b-leveldb β
β (SQLite VFS) β (LevelDB storage.Storage) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β FDB as the backing file store β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Options A and C sit above FDB β they use FDB as a storage substrate and build a new API on top. Options B sit below an existing engine β they replace the file system with FDB while keeping the engineβs logic intact.
This is a crucial architectural distinction. In the A/C case, FDBβs ACID semantics are exposed to the caller. In the B case, FDBβs semantics are hidden inside a file-like abstraction, and the engine above (LevelDB, SQLite) provides its own ACID layer β layered on top of FDBβs.
Interview Questions
Q: What is write amplification and why does it matter?
Write amplification is the ratio of bytes written to disk versus bytes written by the application. A write amplification of 10Γ means writing 1 MB of data causes 10 MB of disk writes. LSM trees have high write amplification (compaction rewrites data multiple times) but low latency writes. B-trees have low write amplification but high random-write I/O. At scale, write amplification directly determines storage cost and SSD wear.
Q: Why does an LSM tree use multiple levels?
Compaction merges SSTables from one level into the next. Without levels, youβd need to merge all SSTables at once β an O(total_data) operation for every write. Leveled compaction keeps each level to a fixed size ratio (~10Γ), so each compaction is bounded. This bounds read amplification too: there are at most one SSTable per key per level, so a lookup checks at most L SSTables.
Q: What is the difference between a WAL and an SSTable?
A WAL (Write-Ahead Log) is an append-only sequence of write operations β it records WHAT changed, not the final state. Itβs used for crash recovery. An SSTable is an immutable, sorted snapshot of actual data β it stores key-value pairs in sorted order for efficient lookup. The WAL is ephemeral (truncated after checkpointing). SSTables are persistent (they ARE the data).
Q: What makes FDBβs storage engine different from a traditional B-tree?
FDBβs storage servers use a custom B-tree variant, but the key difference is architectural: FDB is distributed, sharded, and replicated. Each Storage Server holds a shard (a key range). Reads are served directly by Storage Servers (bypassing the Proxy). Writes go through the Transaction Log for durability before being applied to Storage Servers. This means durability and availability are decoupled β you can lose a Storage Server without losing committed data.
FoundationDB in Depth
- 2.1 What FDB Actually Is
- 2.2 The Ordered KV Model β Deep Dive
- 2.3 Transactions: The Full Picture
- 2.4 MVCC β Multi-Version Concurrency Control
- 2.5 Watches, Versionstamps, and Atomic Operations
- 2.6 Cluster Architecture β Every Component Explained
- 2.7 Production Limits and Sizing
- 2.8 The Simulation Harness β Why FDB Is Trusted
- Interview Questions
FoundationDB is one of the most carefully engineered distributed databases ever built. This chapter goes far beyond the API surface β it covers the commit pipeline, MVCC mechanics, cluster architecture, and the simulation harness that makes FDB trustworthy at Apple-scale production.
Where this chapter sits. This is the high-level architectural tour. The next three chapters drill into the parts that most warrant their own treatment: the on-disk storage engine (Chapter 3), the Flow language and simulation harness (Chapter 4), and the performance model in concrete numbers (Chapter 5). Read this chapter first, then those.
2.1 What FDB Actually Is
FoundationDB is a distributed, ordered, transactional keyβvalue store. Each word carries precise meaning:
Distributed: data is automatically sharded across machines. You do not choose a partition key. FDBβs shard boundaries move dynamically as data grows or machines are added. The client library discovers shard locations by reading the cluster file and caching routing information.
Ordered: keys are stored in lexicographic byte order across the entire cluster. GetRange(begin, end) returns keys in sorted order regardless of which Storage Servers hold them. This is the single most powerful property for building layers, because co-location of related data is entirely under your control via key encoding.
Transactional: full ACID across arbitrary key ranges, on multiple machines, at unlimited scale. This is rare. DynamoDB offers single-item atomicity (later added limited cross-item transactions). Cassandra offers lightweight transactions (LWT) at one consistency level. Redis offers multi-key transactions (MULTI/EXEC) without conflict detection. FDB offers true serializable transactions with automatic conflict detection and retry β across any number of keys, on any number of machines.
Keyβvalue: the value is an opaque byte string (max 100 KB). FDB does not care about structure inside the value. All richness is imposed by the layer above β this is by design. It keeps FDBβs guarantees simple and composable.
2.2 The Ordered KV Model β Deep Dive
The FDB key space is conceptually a single sorted array of byte arrays, spanning the entire cluster:
Key space (lexicographic byte order):
"" (smallest possible key)
...
"\x00"
"\x00\x00"
...
"apple"
"apple\x00price" β prefix scan works naturally
"apple\x00weight"
"banana"
...
"\xff\xff\xff..." (largest possible key)
Range semantics β half-open intervals:
All FDB ranges are [begin, end) β inclusive begin, exclusive end. This is universal in FDBβs API:
fdb.KeyRange{Begin: fdb.Key("apple\x00"), End: fdb.Key("apple\x01")}
// Returns: apple\x00price, apple\x00weight, apple\x00... everything
// Does NOT return: apple\x01, banana, ...
The trick of incrementing the last byte (\x00 β \x01) creates a tight upper bound for prefix scans. This pattern appears in every layer in this repo.
Why ordering is the enabler for layers:
Without ordering, you could only do point lookups (O(1) Get). Ordering enables:
- Prefix scans: all keys for user
42(range scan onuser:42:*) - Range queries: all records with
age BETWEEN 25 AND 40(range scan on encoded age values) - Sorted iteration: return all records in PK order (scan the records subspace)
- Pagination: βnext pageβ = resume range scan from last seen key
Every SQL feature that involves ORDER BY, BETWEEN, prefix LIKE, or primary key range is implementable purely through ordered range scans.
2.3 Transactions: The Full Picture
ACID Guarantees
FDB transactions implement Strict Serializability (Herlihy & Wing, 1990): the strongest isolation level. It is equivalent to:
- Serializability (transactions appear to execute one at a time)
- Real-time ordering (if T1 commits before T2 starts, T1 appears before T2)
Most databases offer weaker levels by default. PostgreSQL defaults to βRead Committedβ (not serializable). MySQL InnoDB defaults to βRepeatable Readβ. FDB always runs at strict serializability β there is no weaker option.
The Commit Pipeline
This is the key to understanding FDBβs performance characteristics:
Client Proxy Resolver Transaction Log Storage Servers
β β β β β
β 1. Begin Txn β β β β
β (local, no network) β β β β
β β β β β
β 2. Reads β GetReadVersion β β β
β ββββββββββββββββββββββΊβ β β β
β βββββββββββββββββββββββ readVersion=v100 β β β
β β β β β
β 3. rt.Get(key) β β β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β (reads served directly by Storage Servers β Proxy NOT in critical read path) β
β β β β β
β 4. tr.Set(key,val) β β β β
β (buffered locally, no network yet) β β β
β β β β β
β 5. Commit: send {readVersion, readSet, writeSet} β β
β ββββββββββββββββββββββΊβ β β β
β β 6. Assign commitVersion=v101 β β
β β βββββββββββββββββββΊβ β β
β β 7. Check conflictsβ β β
β β (did anyone write a key in readSet β β
β β between v100 and v101?) β β
β β ββββββββββββββββββββ β β
β β 8. If no conflict: write to TLog β β
β β ββββββββββββββββββββββββββββββββββββββββββΊβ β
β β 9. TLog confirms durability (f+1 copies) β β
β β βββββββββββββββββββββββββββββββββββββββββββ β
β 10. Commit ack β β β β
β βββββββββββββββββββββββ β β β
β β (Storage Servers apply writes asynchronously from TLog) β
Key insight from the pipeline:
- Reads bypass the Proxy β they go directly to Storage Servers. This means read throughput scales linearly with Storage Server count.
- Writes buffer locally β no network traffic until commit. A transaction that writes 1,000 keys generates exactly one network round-trip.
- Conflict checking is done by the Resolver β a separate stateless process that checks whether any key in the transactionβs read set was written by another transaction between
readVersionandcommitVersion. No lock managers, no 2PL. - Durability before acknowledgment β FDB doesnβt ack a commit until the write is in the Transaction Log on
f+1machines. Storage Servers may lag behind.
Retry Semantics
The db.Transact(func) loop handles retries automatically. The function is called again if:
- A conflict is detected (error code 1020)
- The transaction is too old (error code 1007 β MVCC window expired)
- A transient network error occurs
The retry rule: the function passed to Transact must be idempotent for side effects outside FDB. FDB operations are always safe to retry (each retry gets a fresh transaction). But if your function sends an email, charges a credit card, or writes to another database, a retry will do that twice.
// WRONG: email sent on retry
db.Transact(func(tr fdb.Transaction) (interface{}, error) {
tr.Set(key, value)
sendWelcomeEmail(user) // β sent multiple times on conflict!
return nil, nil
})
// RIGHT: defer side effects to after commit
_, err := db.Transact(func(tr fdb.Transaction) (interface{}, error) {
tr.Set(key, value)
return nil, nil
})
if err == nil {
sendWelcomeEmail(user) // β exactly once
}
Read-Only Transactions
db.ReadTransact(func) opens a read-only transaction. It:
- Does not track a write set
- Does not need a commit round-trip
- Cannot conflict (reads never conflict with other reads)
- Uses a recent committed version as the read version
Read-only transactions are cheaper and should be preferred for any operation that only reads data.
2.4 MVCC β Multi-Version Concurrency Control
FDB stores multiple versions of each key, identified by a monotonically increasing version number (a 64-bit integer incremented globally for every commit):
Version timeline:
v100: Set("color", "red") β committed at v100
v101: Set("count", "5") β committed at v101
v102: Set("color", "blue") β committed at v102
v103: Set("count", "6") β committed at v103
A transaction reading at version v101 sees:
"color"="red"(latest version β€ v101)"count"="5"(latest version β€ v101)
A transaction reading at version v103 sees:
"color"="blue"(latest version β€ v103)"count"="6"(latest version β€ v103)
The 5-second window:
FDB garbage-collects old versions after approximately 5 seconds. This is configurable but the default. A transaction that starts at v100 and then tries to read 6 seconds later will get error 1007 (transaction_too_old). This is why long-running transactions are not supported in FDB.
This is a fundamental design choice. FDB optimizes for high-throughput short transactions. If you need to read data while performing long-running computation (e.g., a streaming job), the correct pattern is:
- Read a batch of data, process it
- Commit the batch
- Read the next batch in a new transaction
MVCC vs. Locking
| Feature | MVCC (FDB, PostgreSQL) | Locking (older MySQL) |
|---|---|---|
| Reads block writers | No | Yes (shared lock) |
| Writers block reads | No | Yes (exclusive lock) |
| Deadlocks | Not possible | Possible (lock cycles) |
| Stale reads | Possible if version too old | Not possible |
| Overhead | Garbage collection of old vers | Lock manager overhead |
MVCC is strictly better for read-heavy workloads and any workload that mixes reads and writes.
2.5 Watches, Versionstamps, and Atomic Operations
These three primitives are rarely covered in introductory FDB material but are essential for building production systems.
Watches
tr.Watch(key) returns a FutureNil that fires when the keyβs value changes. The watch is registered at commit time and fires asynchronously.
Production use cases:
- Distributed lock re-acquisition: hold a lock key; watch it to know when released
- Cache invalidation: watch a βcache bustβ key; invalidate local cache when it fires
- Event notification: watch a βlatest eventβ key; re-read when it changes
- Leader election: watch the leader key; trigger re-election when itβs cleared
// Polling-free pub/sub pattern
func watchForChanges(db fdb.Database, key fdb.Key) {
for {
var watch fdb.FutureNil
db.Transact(func(tr fdb.Transaction) (interface{}, error) {
watch = tr.Watch(key)
return nil, nil
})
watch.Get() // blocks until key changes
// process the change...
}
}
Limits: FDB recommends no more than 10,000 active watches per client. Each watch consumes resources on the Storage Server holding that key.
Versionstamps
A versionstamp is a 10-byte identifier that is globally unique and monotonically increasing. It consists of the commit version (8 bytes) plus a user-assigned offset (2 bytes) for ordering within one transaction.
tr.SetVersionstampedKey(keyTemplate, value): writes a key where one 10-byte slot in keyTemplate is replaced with the commit versionstamp.
// Building a totally ordered event log β no coordination required
keyTemplate := append([]byte("events:"), make([]byte, 10)...) // 10-byte placeholder
keyTemplate = append(keyTemplate, []byte(":metadata")...)
tr.SetVersionstampedKey(fdb.Key(keyTemplate), eventPayload)
// After commit: key is "events:<10-byte versionstamp>:metadata"
// Every write gets a globally unique, strictly ordered key
Why this is powerful:
- A sequence of commits from different clients, on different machines, all get globally ordered keys
- No sequence number coordinator required
- Reading all events in order = a single
GetRange("events:", "events\x01")scan
This is how fdb-record-layer implements change feeds and how any FDB-based event sourcing system should be built.
Atomic Operations
Atomic operations modify a key without reading it in the same transaction. They commute β two concurrent Add(key, 1) calls on the same key will both succeed, with a final result of 2, even if they had the same read version.
| Operation | Semantics | Use case |
|---|---|---|
Add(key, n) | Atomic 64-bit integer addition | Counters, sequence numbers |
BitAnd/Or/Xor | Bitwise operation on the value | Bit set membership |
Max/Min | Atomic max or min of the current value | High-water marks, metrics |
SetVersionstamped | Stamp the commit version into key/value | Ordered event logs |
CompareAndClear | Clear key if current value equals argument | Expiry, conditional delete |
AppendIfFits | Append bytes if result fits in value size | Accumulator patterns |
Critical property: atomic ops do not generate a read conflict for their key. Two transactions can both do tr.Add("counter", 1) without conflicting. This is fundamentally different from read-modify-write (which would conflict).
Under the hood: atomic ops are sent to the Storage Server as special mutation records that the server applies during version materialization. The server has the current value and applies the operation when making the value visible.
2.6 Cluster Architecture β Every Component Explained
βββββββββββββββββββββββββββββββββββ
β Client Process β
β fdb.OpenDatabase(clusterFile) β
ββββββββββββββ¬βββββββββββββββββββββ
β reads cluster file once;
β caches routing table
ββββββββββββββΌβββββββββββββββββββββ
β Coordinators (3β5) β
β Paxos-based consensus β
β Store: cluster configuration β
β Elect: active Proxies, TLogs, β
β Data Distributor, β
β Ratekeeper β
ββββββββββββββ¬βββββββββββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββ
βΌ βΌ βΌ
βββββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ
β Proxies β β Resolvers β β Transaction Log β
β (GRV + commit) β β (conflicts) β β (WAL, durable) β
ββββββββββ¬βββββββββ ββββββββββββββββ ββββββββββββββββββββ
β after conflict check + TLog commit
β Storage Servers apply writes asynchronously
ββββββββββΌβββββββββββββββββββββββββββββββββββββββ
β Storage Servers (many) β
β Each holds a shard (contiguous key range) β
β Serves reads directly; applies mutations β
β from TLog β
ββββββββββββββββββββββββββββββββββββββββββββββββββ
Proxies (GRV Proxies + Commit Proxies)
FDB 7.x splits proxy responsibilities:
- GRV Proxies (Get Read Version): assign read versions to transactions. This is a global operation β all reads in the cluster must see a consistent version ordering. GRV proxies serialize these assignments.
- Commit Proxies: receive commit requests, forward to Resolvers for conflict checking, then write to the Transaction Log.
Proxies are stateless (they hold no data). They can be added or removed without data migration.
Resolvers
Resolvers track which keys were written in each recent commit. When a Commit Proxy submits a transactionβs read set, the Resolver checks: βwas any key in this read set written by a committed transaction between this transactionβs read version and now?β If yes β conflict, retry. If no β proceed.
Resolvers are in-memory only (they hold recent write history, not persistent data). Their state window matches the MVCC window (~5 seconds of commits).
Transaction Log
The TLog is FDBβs Write-Ahead Log β but distributed. Each commit is written to f+1 TLog machines before being acknowledged to the client. TLog uses its own durable storage (either fdatasync-safe files or SSDs).
After Storage Servers confirm they have applied all mutations up to a version (the βdurable versionβ), TLog can truncate its older entries.
Storage Servers
Each Storage Server holds a subset of the key space (a shard). When a client wants to read a key, it looks up which Storage Server holds that keyβs shard (from its cached routing table), and sends the read directly to that server.
Storage Servers use a custom storage engine (called βKeyValueStoreβ internally) built on a variant of SQLite or a custom B-tree, depending on configuration. Each mutation from the TLog is applied to this local store.
Drill-down: the storage engine deserves its own chapter β see Chapter 3 β Storage Engines for the full SQLite β Redwood β Sharded RocksDB story, with source pointers and page-layout diagrams.
Data Distribution: The Data Distributor process monitors shard sizes and access patterns. If a shard grows too large (>250MB by default), it splits it and migrates half to another Storage Server. This is transparent to clients.
Coordinators
Coordinators run a Paxos-like consensus protocol to maintain the βcluster controllerβ β the process that manages the lifecycle of all other FDB roles. If the cluster controller fails, coordinators elect a new one. Coordinators themselves are stateless beyond their Paxos log.
The cluster file (fdb.cluster) contains just the coordinator addresses: fdbdemo:fdbdemo@127.0.0.1:4500. This single file is how clients bootstrap their connection to any FDB cluster.
2.7 Production Limits and Sizing
These are not academic β they affect how you design your layer:
| Parameter | Limit / Default | Implication |
|---|---|---|
| Max value size | 100 KB (102,400 bytes) | Large values must be chunked (option-b) |
| Max transaction size | ~10 MB (reads + writes combined) | Bulk loads must be split |
| Max transaction duration | ~5 seconds (MVCC window) | No long-running transactions |
| Max watches per client | ~10,000 | Watches are not free |
| Max key size | 10 KB | Keep keys short (< 1 KB preferred) |
| Read version latency | ~1 ms (local datacenter) | Fast; dominated by network |
| Commit latency | ~2β5 ms (local datacenter) | TLog write + network |
| Max throughput (single proxy) | ~250,000 operations/sec | Scale horizontally by adding proxies |
| Storage per server | ~100 GBβ4 TB typical | FDB handles redistribution automatically |
Production rule of thumb: keep transactions small (< 1 MB), short (< 1 second), and focused (few key ranges). This maximizes throughput and minimizes conflicts.
2.8 The Simulation Harness β Why FDB Is Trusted
FDBβs simulation framework is the most sophisticated correctness testing system in any open-source database. Understanding it explains why Apple, Snowflake, and others trust FDB with mission-critical data.
How Simulation Works
The entire FDB cluster β network stack, disk I/O, clocks, process scheduling β is implemented twice:
- The real implementation (for production)
- A deterministic simulation (for testing)
In simulation mode:
- All network calls are function calls (no actual sockets)
- Disk I/O is simulated in memory with configurable failure injection
- Clocks are virtual (controlled by the simulator)
- Processes are coroutines (no real threads)
- Random seeds control all non-determinism
The simulator injects:
- Machine failures: simulate killing any process at any time
- Network partitions: drop messages between specific nodes
- Disk failures: simulate corrupted writes, partial writes
- Clock skew: advance clocks non-uniformly across nodes
- Slow operations: simulate disk latency spikes
Why Determinism Matters
When a bug is found in simulation:
- Record the random seed that triggered it
- Reproduce the exact same sequence of events with the same seed
- Add print statements, breakpoints, rerun β identical behavior every time
This is impossible with real distributed systems, where timing non-determinism makes bugs nearly unreproducible.
Scale of Testing
Before each FDB release, the simulation runs for thousands of machine-years worth of simulated time. A team member once stated: βWeβve found more bugs through simulation than through all other methods combined.β
Drill-down: the simulation harness, the Flow language itβs built on, and the source-level mechanics that make determinism possible are covered in Chapter 4 β Flow, Actors, and Simulation.
This testing discipline is why FDB can make strong correctness guarantees. Itβs also why the FDB team is one of few database teams in the world that will confidently claim their ACID implementation is correct under arbitrary failure injection.
What This Means for You
When you use FDB as your storage substrate, you inherit these correctness guarantees. The five layers in this repository can fail in arbitrary ways β the processes can crash, the network can partition β and FDB will maintain consistency. The only failure that can cause data loss is the simultaneous loss of f+1 machines during a transaction commit window (where f is the configured fault tolerance level, typically 1 for a 3-machine cluster or 2 for a 5-machine cluster).
Interview Questions
Q: What is strict serializability and how does it differ from serializability?
Serializability means transactions appear to execute in some sequential order. Strict serializability (also called linearizability + serializability) additionally requires that the sequential order respects real-time ordering: if transaction T1 commits before transaction T2 begins, T1 must appear before T2 in the serial order. This matters for distributed systems where different clients observe commits at different times. FDB provides strict serializability. Most databases (PostgreSQL serializable, MySQL serializable) provide only serializability.
Q: How does FDB avoid deadlocks?
FDB doesnβt use locks. Instead, it uses optimistic concurrency control: reads are conflict-tracked, writes are buffered, and conflict detection happens at commit time. If a conflict is detected, the transaction retries with a new read version β it never βwaitsβ for another transaction to release a lock. Since thereβs no waiting, there canβt be a cycle of transactions waiting on each other.
Q: What happens to a running FDB transaction if the Proxy crashes?
The clientβs Transact loop handles this. The proxy failure will manifest as a network error on the commit request. The FDB client library translates this to a retriable error, and Transact calls the function again with a new transaction and a new proxy (the client discovers the new proxy from the Coordinator). The retry is transparent to application code.
Q: Why does FDB limit transaction size to 10 MB?
FDBβs conflict resolution requires the Resolver to hold recent write history in memory. Large transactions would require holding large amounts of data in the Resolverβs conflict window. Additionally, the Transaction Log must write the entire transaction atomically before acknowledging. Large transactions create tail latency. The 10 MB limit keeps both of these bounded. For bulk loading, the correct approach is to batch writes in 1β5 MB transactions.
Q: What is a βread versionβ in FDB?
A read version is a 64-bit integer that represents a point in time in FDBβs commit history. Every committed transaction increments the global version counter. A transaction reading at version V sees all commits with version β€ V and no commits with version > V. Read versions are assigned by GRV Proxies and represent a consistent snapshot of the entire cluster at that version.
Q: Can two FDB transactions that donβt share any keys conflict?
No. Conflict detection in FDB is purely key-based. Two transactions with disjoint key sets can never conflict, regardless of how close together they commit. This is why carefully designed key spaces (using subspaces to isolate different logical entities) dramatically reduce conflict rates in high-throughput applications.
Storage Engines: From SQLite to Redwood to Sharded RocksDB
- 3.1 The Storage Server Interface
- 3.2 Era One: KeyValueStoreSQLite (2013 β 2021)
- 3.3 Era Two: Redwood (2020 β present)
- 3.4 Era Three: Sharded RocksDB (2022 β present)
- 3.5 The Memory Engine β Not Just for Testing
- 3.6 The βOuter MVCCβ β How All Three Engines Look the Same to FDB
- 3.7 Putting It Together: A Worked Example
- 3.8 What This Means for the Labs
- Interview Questions
βFoundationDBβs storage engine is the part most outsiders get wrong. They picture a thin shim over RocksDB. The reality is three engines, fifteen years of evolution, and a bytecode interpreter that runs inside a B+tree.β
The previous chapter covered FoundationDBβs distributed plane: proxies, resolvers, transaction logs, and the cluster controller. This chapter zooms into the box labeled βStorage Serverβ and asks what is actually written to disk.
The answer has changed three times since FoundationDB became public in 2013:
| Era | Storage engine | Code module | When |
|---|---|---|---|
| 1.x β 6.x | ssd-1 / ssd-2 β a fork of SQLiteβs B-tree (KeyValueStoreSQLite) | fdbserver/KeyValueStoreSQLite.actor.cpp | 2013 β 2021 (default) |
| 6.2 β present | memory β in-memory radix tree with on-disk WAL | fdbserver/KeyValueStoreMemory.actor.cpp | always (small datasets) |
| 6.3 β present | ssd-redwood-1 (Redwood) β purpose-built versioned B+tree | fdbserver/VersionedBTree.actor.cpp | 2020 (experimental) β 2023 (production) |
| 7.1 β present | ssd-rocksdb-v1 (Sharded RocksDB) | fdbserver/KeyValueStoreRocksDB.actor.cpp | 2022 (one-shard) β 2024 (sharded production) |
If your fdbserver was compiled in the last year and you typed
configure ssd, you got Redwood. If you typed configure ssd-rocksdb-v1
you got Sharded RocksDB. If you typed configure memory you got the in-memory
engine. The original SQLite-based engine is still in the tree (ssd-1/ssd-2)
and still used for catalog data; it is the engine the rest of the world used in
production for the better part of a decade.
Why three engines? Because the abstract interface that a Storage Server needs is very narrow, but the physics underneath it are very picky.
3.1 The Storage Server Interface
A Storage Server is just an IKeyValueStore. The interface is defined in
fdbserver/IKeyValueStore.h:
class IKeyValueStore : public IClosable {
public:
virtual KeyValueStoreType getType() const = 0;
virtual void set(KeyValueRef keyValue, const Arena* arena = nullptr) = 0;
virtual void clear(KeyRangeRef range, const Arena* arena = nullptr) = 0;
virtual Future<Void> commit(bool sequential = false) = 0;
virtual Future<Optional<Value>> readValue(KeyRef key, ...) = 0;
virtual Future<Standalone<RangeResultRef>>
readRange(KeyRangeRef keys, int rowLimit, int byteLimit, ...) = 0;
virtual StorageBytes getStorageBytes() const = 0;
};
Five operations: set, clear, commit, readValue, readRange. Thatβs it.
Every storage engine in FDB implements those five and a handful of
introspection methods. Everything you have ever heard about FDB β MVCC, sharding,
replication, MVCC GC β lives above this interface.
That narrowness is what made it possible to swap SQLite for Redwood for RocksDB without changing the Resolver, the Transaction Log, the Data Distributor, the client library, or any layer.
The Storage Server itself (the role, as opposed to the engine) is
implemented in
fdbserver/storageserver.actor.cpp.
That file is ~10,000 lines of Flow actors that:
- Subscribe to the Transaction Log for mutations.
- Buffer them in an in-memory MVCC structure called the VersionedMap
(a persistent β i.e., immutable-snapshot β radix tree, see
fdbserver/VersionedMap.h). - Periodically write a βdurable versionβ of the VersionedMap into the
underlying
IKeyValueStore. - Serve reads by composing the durable engineβs data with anything newer in the VersionedMap.
So when you read a key from FDB, you are reading from two layers: a recent in-memory MVCC overlay, plus the durable on-disk B-tree (or LSM). The MVCC overlay is what gives you the 5-second window of historical reads; the durable engine is what gives you petabytes of capacity.
3.2 Era One: KeyValueStoreSQLite (2013 β 2021)
When FoundationDB Inc. was founded in 2009, the team made a deliberate decision: donβt write a new B-tree. B-trees are notoriously hard to get right β crash recovery, concurrency, page-level corruption β and SQLiteβs B-tree had already been deployed on a couple billion devices and tested by Richard Hippβs TH3 test suite, which famously exercises 100% MC/DC branch coverage.
So they took SQLiteβs B-tree (btree.c, pager.c, os_unix.c), forked it,
stripped out everything above it (the SQL parser, virtual machine, VFS layer),
and wired the resulting key-value primitive into Flow as
KeyValueStoreSQLite. The fork still lives in the repo at
fdbserver/sqlite/
β the file names will look very familiar to anyone who has read SQLite source.
What βssd-1β actually does on a write
Storage Server receives mutation: set("apple", "1") at version v100
β
βΌ
KeyValueStoreSQLite::set() β in-memory, just a queue
β
βΌ (commit called by storageserver.actor.cpp every "MUTATION_BATCH_INTERVAL"
β β typically 5 ms, configured by KNOB STORAGE_COMMIT_INTERVAL)
β
KeyValueStoreSQLite::commit()
β
ββββΊ sqlite3BtreeBeginTrans(WRITE)
ββββΊ for each mutation: sqlite3BtreeInsert() / sqlite3BtreeDelete()
ββββΊ sqlite3BtreeCommitPhaseOne() β writes WAL frame
ββββΊ sqlite3BtreeCommitPhaseTwo() β fsync, then page move
βΌ
returns Future<Void> that fires when on disk
The SQLite engine runs in rollback-journal mode, not WAL mode (counter- intuitively β SQLiteβs βWALβ is unrelated to FDBβs distributed Transaction Log). The rollback journal works like this:
- Before modifying page X, copy Xβs current contents to a side file
(
*.fdb-rj). - Modify page X in memory.
fsyncthe rollback journal.- Overwrite page X in the main file.
- On crash recovery, if a rollback journal exists, restore each page from it.
This is the same algorithm as PostgreSQLβs older βphysical logβ approach. It
gives crash atomicity at the page level. The cost: every page modification
incurs two writes β one to the journal, one to the main file β plus an fsync.
Why two flavors, ssd-1 and ssd-2?
ssd-1 was the original. ssd-2 is the same engine with a
btree corruption check and a tweaked page size (introduced in FDB 6.1).
In practice you should not be choosing between them; both are legacy.
Why it had to be replaced
Three problems compounded as FDB went to multi-terabyte Storage Servers:
- Page locking. SQLite serializes all access to its pager. Reads and writes contend on a single mutex. With Flow actors generating thousands of concurrent reads, the mutex became a bottleneck around 2016.
- No MVCC. SQLite is a single-writer engine. Every commit has to wait for the previous one to finish. FDB worked around this by batching mutations, but the batch wall-time directly became commit tail latency.
- Write amplification. With 4 KB pages and the rollback journal, write amplification was β 6Γ even before SSD wear leveling.
By 2018 a Storage Server saturating a single NVMe at sustained write throughput was a known hardware-limited operation. The team decided to write a purpose-built engine that knew it was running inside an MVCC system.
3.3 Era Two: Redwood (2020 β present)
Redwood is a versioned, copy-on-write, prefix-compressed B+tree with
delta encoding inside each page. It is implemented in a single ~10,000-line
file: fdbserver/VersionedBTree.actor.cpp.
The lead designer is Steve Atherton; his FDB Summit 2018 talk
(βRedwood Storage Engineβ) is
required viewing.
Why βversionedβ?
Recall from Β§3.1 that the Storage Server maintains an in-memory VersionedMap and periodically flushes a βdurable versionβ to disk. With SQLite, βflushβ meant replace β the on-disk state always represented one version, and older versions only existed in memory.
Redwood is fundamentally different: the B+tree itself is multi-versioned. Each page has a version. When you read at version V, the tree exposes the view that was current at version V β even if newer versions exist on disk.
Concretely:
Page #42 versions: v100 β [apple=1, banana=2]
v150 β [apple=1, banana=2, cherry=3]
v200 β [apple=9, banana=2, cherry=3]
Read at v160 returns: [apple=1, banana=2, cherry=3] β uses v150
Read at v250 returns: [apple=9, banana=2, cherry=3] β uses v200
This collapses three things into one structure:
- The in-memory MVCC overlay (no longer needed for the 5-s window).
- The durable on-disk tree.
- The βcheckpointβ / snapshot mechanism (a checkpoint is just a frozen version).
The win: a single commit writes new versions of touched pages once, with
no journal copy and no rewrite. Write amplification drops from β6Γ to β1.5Γ.
Page layout
Each Redwood page is laid out (see BTreePage in the source, around line 2200
of VersionedBTree.actor.cpp):
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Header: page type (leaf|internal), height, version, flags β 16 B
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β DeltaTree: prefix-compressed sorted entries β
β β
β anchor key: "user:1024:profile" β
β delta 1 : +3 "_v1" value=... β
β delta 2 : +3 "_v2" value=... β
β delta 3 : skip 4 "_pic" value=... β
β ... β
β β
β Each delta: { prefix_len, suffix_bytes, value_or_ptr } β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The DeltaTree2 structure
(fdbserver/DeltaTree.h)
is the cleverest part: it stores keys as deltas from the previous key in
sorted order, then arranges those deltas in a balanced binary search tree
inside the page so you can binary-search without decompressing the whole page.
A typical FDB workload has 60β80% prefix overlap inside a page (keys like
user:1024:posts:001, user:1024:posts:002, β¦); DeltaTree2 routinely
compresses pages 3β5Γ.
Copy-on-write writes
Writes never modify a page in place. Instead:
- The mutation logically applies to page #42.
- A new physical page (say #9817) is allocated containing the new content.
- Page #42βs parent in the B+tree is rewritten to point to #9817 (this
recurses to the root β
O(log N)new pages per commit). - The old page #42 is not freed immediately. It is kept alive until the oldest readerβs version is past it.
- A background actor periodically scans the page allocatorβs free queue and
reclaims pages whose
oldest_reader_versionconstraint is satisfied.
The free-page allocator is itself a B+tree (a βqueue of free pagesβ), making
allocation transactional. See FIFOQueue in
fdbserver/FIFOQueue.h.
Concurrency
Redwood uses lock-free reads: a reader takes a snapshot of the root pointer for its version, then walks down. Because pages are copy-on-write, no other writer can mutate the pages the reader is walking β they will allocate new pages instead. This is the same trick used by LMDB and CouchDB.
There is exactly one writer thread per Redwood instance. With one writer, write serialization is implicit; no page locks needed.
What to read in the source
| File | What to look at |
|---|---|
fdbserver/VersionedBTree.actor.cpp | class VersionedBTree, commit(), commitSubtree() |
fdbserver/DeltaTree.h | DeltaTree2, the prefix-compressed in-page format |
fdbserver/FIFOQueue.h | The transactional free-page allocator |
fdbserver/IPager.h | The pager interface Redwood sits on |
fdbserver/Pager.h, Pager.cpp | The actual paging implementation (DWALPager) |
The DWALPager (βDirect-Write-Ahead-Log Pagerβ) is Redwoodβs WAL. Like the
distributed Transaction Log, it lets the engine acknowledge commits before
the B+tree itself has been fully updated, then flushes lazily.
How to know if youβre running Redwood
fdbcli> status details
Look at each Storage Server line:
10.0.0.5:4500 (...) class=storage storage_engine=ssd-redwood-1
In Go-binding clients there is no API for this β it is configured at the
cluster level via fdbcli> configure ssd-redwood-1-experimental (the suffix
was dropped in 7.3, but the keyword survives for backward compat).
3.4 Era Three: Sharded RocksDB (2022 β present)
The third engine is a deliberate move toward leveraging a battle-tested LSM implementation. The motivation is not technical envy; it is operational reality:
- RocksDB has decades of investment in compaction tuning, write throttling, rate limiters, and observability hooks.
- Many FDB consumers (Snowflake, Apple) already run RocksDB elsewhere in their stack and have on-call expertise.
- LSMs match write-heavy workloads better than B+trees, and a meaningful fraction of FDB deployments are write-dominated (event logs, time series).
What βshardedβ means here
This is not LevelDB-style. A single Storage Server in modern FDB hosts
many shards (key ranges, typically ~250 MB each). In ssd-rocksdb-v1
each shard is its own RocksDB instance:
Storage Server process
βββ shard "user:0000..." β /data/shards/abc123/ (one RocksDB tree)
βββ shard "user:0001..." β /data/shards/def456/ (another RocksDB tree)
βββ shard "user:0002..." β /data/shards/ghi789/ (another RocksDB tree)
βββ ...
Why per-shard instances? Because when Data Distribution moves a shard, the Storage Server can physically move the RocksDB directory instead of copying key by key. The destination server just opens the directory in place. This makes data movement nearly instantaneous, and bypasses the compactor entirely.
This trick was prototyped in
Tigrisβs FDB fork
and brought upstream by the Snowflake team. The implementation lives in
fdbserver/KeyValueStoreShardedRocksDB.actor.cpp.
What you give up
- Read amplification. LSM lookups must consult multiple SSTs. For point lookups bloom filters help; for range scans they donβt. Redwood beats RocksDB on read-heavy workloads by a non-trivial margin.
- MVCC native to the engine. The MVCC overlay must come back; Sharded
RocksDB cannot serve historical reads natively, so the VersionedMap in
storageserver.actor.cppis doing real work again. - Snapshot semantics. Cheap with copy-on-write, harder with LSMs.
Where the gains are
- Bulk ingestion. SST file ingestion (
IngestExternalFile) lets backup restore at near-disk-bandwidth rates. - Mature tuning knobs. Rate-limited compaction, prioritized flushes, block cache observability β all features of the engine itself.
When to pick which
| Workload profile | Recommended engine |
|---|---|
| Mixed read/write OLTP | Redwood (default) |
| Write-heavy time-series / event sourcing | Sharded RocksDB |
| Small dataset, < 100 GB | Memory engine |
| Catalog / coordinator state | SQLite (ssd-2) is still used |
3.5 The Memory Engine β Not Just for Testing
KeyValueStoreMemory (source)
is sometimes dismissed as a test fixture. It is actually used in production
for workloads where the entire dataset fits comfortably in RAM and ultra-low
latency matters β Snowflake uses it for some metadata services.
Its design is straightforward:
- An in-memory radix tree holds all keys.
- A disk WAL records every mutation, fsynced before commit ack.
- On restart, the WAL is replayed to reconstruct the tree.
- Periodic compaction rewrites the WAL as a snapshot to bound restart time.
The latency win is real: a set is O(log N) pointer manipulations plus an
append to an open log file. A 64-core Storage Server running the memory engine
can sustain ~500,000 set/sec on a single NVMe, vs ~120,000 for Redwood
on the same hardware. The cost is obvious β capacity is bounded by RAM.
3.6 The βOuter MVCCβ β How All Three Engines Look the Same to FDB
Regardless of engine, the rest of FDB only ever sees:
struct StorageServerState {
VersionedMap<KeyRef, ValueOrClearToRef> versionedData; // in-memory MVCC overlay
IKeyValueStore* storage; // SQLite | Redwood | RocksDB
Version storageVersion; // last durable version
Version oldestVersion; // GC horizon (~5 s old)
Version latestVersion; // most recent applied mutation
};
A read at version v:
- Walk
versionedDatafor the key at versionv. If found, return. - Otherwise, read from
storage(which holds the snapshot as ofstorageVersion). - If
v < oldestVersion, returntransaction_too_old(error 1007).
A mutation:
- Update
versionedDataat the new version. - Update
latestVersion. - Periodically (every ~5 ms or 5 MB, configurable via
STORAGE_COMMIT_INTERVALandSTORAGE_COMMIT_BYTESknobs), flush theversionedDataentries with version β€ some cutoff intostorage, then advancestorageVersion.
The flush itself is the engine-specific part β Redwood writes copy-on-write pages, Sharded RocksDB writes to a memtable, SQLite writes to its journal.
This separation explains why FDB can βupgradeβ your storage engine via a rolling restart: stop one Storage Server, change its config to use the new engine, start it back up, and the Data Distributor will gradually move shards to it. No data migration script β just shard relocation, which FDB does anyway.
3.7 Putting It Together: A Worked Example
A client commits a transaction at commitVersion = v100 that sets
apple = 1 and clears banana. The Storage Server holding both keys is
running Redwood. Here is the byte-by-byte journey:
| Step | Where | What happens |
|---|---|---|
| 1 | Transaction Log β Storage Server | Mutation (v100, set apple=1, clear banana) arrives via pull RPC |
| 2 | storageserver.actor.cpp, update() | Mutation appended to in-memory versionedData at v100 |
| 3 | Client read at v100 returning apple | Hits versionedData overlay β returns 1 immediately, no disk I/O |
| 4 | (5 ms later, STORAGE_COMMIT_INTERVAL) | Storage Server calls storage->commit(v100) |
| 5 | Redwood VersionedBTree::commit() | Walks pending mutation buffer |
| 6 | DeltaTree2 insert | Page #42 (βa-prefixβ leaf) needs apple=1 added at version v100 |
| 7 | Pager newPageID() | Allocates page #9817 from free queue |
| 8 | DeltaTree2 serializer | Writes new page contents to buffer |
| 9 | Parent page rewrite | Page #7 (internal) needs new child pointer β allocate #9818 |
| 10 | Root page rewrite | New root page allocated |
| 11 | DWALPager writePage() Γ 3 | Buffered, not yet fsyncβd |
| 12 | DWALPager WAL record append | One WAL record covering all page writes |
| 13 | fsync on WAL | Single fsync; commit can be acknowledged from here |
| 14 | (later, background) | New pages flushed to their main file offsets |
| 15 | (5 s later) | Old pages #42, #7, old-root freed via the FIFO free queue |
Notice: one fsync per commit batch, not per mutation. With a 5 ms batch
window and ~5,000 mutations per batch at peak load, this gives Redwood its
characteristic β250,000 mutations/sec/disk throughput on commodity NVMe.
3.8 What This Means for the Labs
The five labs in this repo treat FDB as a black-box ordered KV store. But the performance of each lab is shaped by which engine is underneath:
- Option B labs (SQLite/LevelDB on FDB). They store pages (4β32 KB) as values. Redwoodβs prefix compression buys you nothing here β pages donβt share prefixes. RocksDB sharded engine, with native block compression, is usually faster for this workload.
- Option A labs (KV API / SQL above FDB). They store many small keys with
shared prefixes (
table/pk/...,index/col/val/pk/...). Redwoodβs DeltaTree2 will compress these aggressively. Redwood is the better default. - Option C (Record Layer). Same prefix-heavy story as Option A. Redwood wins.
Try this experiment with the labs:
# Default cluster: Redwood
docker compose up -d
./scripts/bootstrap-fdb.sh
cd option-c-record-layer && go run ./demo
# Re-configure to memory engine and re-run
docker exec -it $(docker ps -qf name=fdb) fdbcli --exec "configure memory"
cd option-c-record-layer && go run ./demo
# Notice the latency drop on commits, but capacity is now RAM-bounded
Interview Questions
Q: Why did FoundationDB build Redwood instead of using RocksDB or LMDB?
Three reasons. First, RocksDB at the time of Redwoodβs design (2017β2019) did
not have efficient native MVCC; FDB already maintained an in-memory MVCC overlay
and wanted to push that down into the engine. Second, FDBβs workload has
heavy prefix overlap (long encoded keys like \x15\x01user\x00\x15\x01...)
and RocksDB had no prefix compression inside a block. Third, FDBβs
copy-on-write semantics are a poor match for LSM compaction overhead. Redwood
was designed to fit FDBβs existing MVCC model rather than fight it.
Q: How does Redwood reclaim free pages without breaking historical reads?
Each page has a version when freed. The free-page queue stores tuples
(pageID, versionFreed). The pager only re-allocates a page when
versionFreed < oldestReaderVersion across all live transactions. Because
FDBβs MVCC window is ~5 seconds, free pages typically take 5β10 s to become
re-allocatable. The free queue is itself a B+tree, so the bookkeeping is
transactional.
Q: What is the difference between FDBβs Transaction Log and an engineβs WAL?
FDBβs Transaction Log is the distributed WAL β f+1 copies of every commit
written across machines before client ack. The engineβs WAL (e.g.,
Redwoodβs DWALPager, SQLiteβs rollback journal, RocksDBβs MANIFEST/WAL) is
the local WAL for that one Storage Server, used to recover that serverβs
on-disk state if it crashes before its in-memory pages were flushed. The
durability guarantee comes from the Transaction Log; the engineβs WAL is
purely for local crash recovery.
Q: Why does the Memory engine still need a disk WAL?
So that commits survive a process crash. The in-memory radix tree is rebuilt from the WAL on startup. Without the WAL, a restart would lose data even if FDBβs distributed Transaction Log retained the commits β the engine couldnβt re-derive its state quickly. The WAL turns recovery into an O(WAL size) sequential read instead of an O(dataset) cross-cluster re-replication.
Q: A Storage Server is being moved to a new machine. Compare what happens under SQLite vs. Sharded RocksDB.
Under SQLite: the source Storage Server streams every key in the shardβs range to the destination over the network, which then writes each key into its own SQLite B-tree. Cost: O(shard size) bytes over network, O(shard size) random writes on the destination disk. Under Sharded RocksDB: the source copies the SST files of that shardβs RocksDB directory to the destination, which opens the directory directly. Cost: O(shard size) bytes over network, but writes on the destination are sequential SST file writes β no key-by-key insertion overhead. The latter is typically 5β10Γ faster wall-time.
Flow, Actors, and Simulation
- 4.1 The Problem Flow Solves
- 4.2 The Actor Model, FDB Edition
- 4.3 Futures and Promises
- 4.4 The Event Loop
- 4.5 Network = Just Another Future
- 4.6 The βKnobsβ System
- 4.7 Simulation: How Bugs Get Caught Before You
- 4.8 Why You Care When You Use FDB from Go
- 4.9 Hands-On: Reading One Actor End-to-End
- Interview Questions
βFlow is the C++ language extension I always wanted but was too lazy to write. The FoundationDB team wrote it, and then they wrote a distributed database in it.β β paraphrased from a former Apple engineer
If you grep apple/foundationdb for ACTOR, you get ~7,500 hits. If you grep
for Future<, you get ~25,000 more. Every line of FoundationDB β
client library, Storage Server, Resolver, Proxy, Coordinator β is written in
Flow, a C++14-compatible language extension for asynchronous,
single-threaded, deterministic concurrency.
Flow is not a library. It is a source-to-source compiler that takes
*.actor.cpp files, expands ACTOR / wait() / loop keywords into giant
state machines, and emits ordinary *.g.cpp files that any C++14 compiler
will accept. The Flow source is in
flow/ and the
compiler itself is in
flow/actorcompiler/
β it is a 4,000-line C# program (yes, C# β leftover from when Appleβs tooling
team owned the build).
You cannot read FoundationDB source without reading Flow. This chapter teaches the dialect.
4.1 The Problem Flow Solves
A database server is one of the worldβs worst fits for traditional threading:
- A single Storage Server holds tens of thousands of simultaneous in-flight RPCs β reads, mutation pushes from TLogs, watch notifications, status pings.
- Each RPC may block on disk, network, or another RPCβs reply.
- The latency budget for a single read is β1 ms β most of which is network.
- Lock contention and context-switching costs are catastrophic at this scale.
The 2010-era options were:
- Threads + mutexes β too slow, deadlocks, race conditions.
- Callback chains (βcallback hellβ) β fast, but unreadable.
- Coroutines (
Boost.Asiostackful) β readable, but slow and heavy.
The FDB team wanted callback performance with linear-code readability, plus something more: determinism, so that the simulation harness (Β§4.7) could reproduce any bug. None of the existing options gave determinism.
So they invented Flow.
4.2 The Actor Model, FDB Edition
In Flow, an Actor is a function that may suspend at any wait() call,
yielding control back to the event loop (a single OS thread per process).
While suspended, other actors run. When the awaited future completes, the actor
resumes from where it left off.
ACTOR Future<Value> readWithRetry(Database db, Key key) {
state Transaction tr(db);
loop {
try {
Value v = wait(tr.get(key));
return v;
} catch (Error& e) {
wait(tr.onError(e));
}
}
}
Three keywords are not standard C++:
ACTORβ marks the function as Flow-compiled. The compiler will replace this with a class that holds the functionβs local state and astep()method.stateβ marks a local variable as needing to survive acrosswait()calls. Plain locals are not preserved.wait()β suspends the actor until the givenFuture<T>is ready, then resumes with its value. Only legal insideACTORfunctions.loopβ syntactic sugar forwhile (true) { ... }, distinct because the compiler optimizes the surrounding state machine.
What the compiler emits (simplified) for the above:
// machine-generated VersionedBTree.actor.g.cpp
class ReadWithRetryActor : public Actor<Value> {
Database db;
Key key;
Transaction tr; // β state var, lives across waits
int pc = 0; // program counter
void step() {
switch (pc) {
case 0: // initial entry
tr = Transaction(db);
pc = 1; [[fallthrough]];
case 1: { // loop top, before get
Future<Value> f = tr.get(key);
if (!f.isReady()) {
f.addCallback([this]{ pc = 2; step(); });
return;
}
pc = 2; [[fallthrough]];
}
case 2: { // after wait, returned
try {
Value v = futureGet<Value>();
sendResult(v);
return;
} catch (Error& e) {
Future<Void> r = tr.onError(e);
if (!r.isReady()) {
r.addCallback([this]{ pc = 1; step(); });
return;
}
pc = 1; goto case_1; // loop
}
}
}
}
};
That generated code is what your CPU actually executes. The ACTOR syntax is
purely a compile-time convenience. You can read the actual generated .g.cpp
files by building the project; CMake emits them into the build directory.
4.3 Futures and Promises
Flowβs Future<T> is not std::future<T>. It is a smart-pointer to a
small SAV (βSingle Assignment Variableβ) object that holds either:
- the value
T(once set), or - a list of callbacks waiting for it, or
- an error.
Promise<T> is the writer end. The pattern:
Promise<int> p;
Future<int> f = p.getFuture();
p.send(42); // sets the value, fires all callbacks
// later:
int x = wait(f); // (inside an ACTOR) returns 42 immediately if ready
The single-assignment rule (send exactly once) is enforced at runtime.
There is no shared state synchronization, because the entire event loop
runs on one thread. This eliminates a whole class of concurrency bugs
and is the foundation of Flowβs determinism.
Source: flow/include/flow/flow.h,
look for class Future, class Promise, class SAV.
4.4 The Event Loop
Net2 (flow/Net2.actor.cpp)
is FDBβs event loop. Pseudo-code:
while (running) {
Time now = monotonicTime();
// 1. Run all ready callbacks
while (!readyQueue.empty()) {
readyQueue.pop()->fire();
}
// 2. Run any timers that have expired
while (!timerHeap.empty() && timerHeap.top().t <= now) {
timerHeap.pop().fire();
}
// 3. Poll the network (epoll/kqueue) and disk (io_uring/AIO)
int timeoutMs = computeNextTimerDelay();
pollEvents(timeoutMs);
// β pushes any newly-ready I/O completions onto readyQueue
// 4. (optional) yield to the OS to maintain low priority for system tasks
if (cpuHeavy) { sched_yield(); }
}
Three things to internalize:
- One thread. Every
wait()resumes on the same thread as the originalACTORinvocation. No data races, no mutexes inside Flow code. - Cooperative scheduling. An actor must
wait()(or return) to yield. A CPU-heavy actor that doesnβtwait()will block every other actor in the process β including network I/O. Flow has utilities likeyield(taskPriority)to voluntarily reschedule. - Priorities. Every actor runs at a
TaskPriority(seeflow/include/flow/Knobs.h). The event loop runs higher priorities first. This is how FDB keeps tail latency bounded: critical-path actors (commit pipeline) run at higher priority than background actors (data distribution, status reporting).
4.5 Network = Just Another Future
The single most elegant thing in Flow is that the network is presented exactly like any other asynchronous operation:
ACTOR Future<Void> echoServer(NetworkAddress addr) {
state Reference<IListener> listener = INetworkConnections::net()->listen(addr);
loop {
Reference<IConnection> conn = wait(listener->accept());
echoOne(conn); // fire-and-forget child actor
}
}
ACTOR Future<Void> echoOne(Reference<IConnection> conn) {
loop {
wait(conn->onReadable());
std::vector<uint8_t> buf(1024);
int n = conn->read(buf.data(), buf.size());
if (n == 0) return Void();
wait(conn->onWritable());
conn->write(buf.data(), n);
}
}
IConnection is an interface. There are two implementations:
Net2::Connectionβ backed by real Berkeley sockets viaepoll/kqueue. Used in production.Sim2::Connectionβ backed by a simulated network. Used in tests. Same actor code runs unchanged.
This is the magic: you write one networking implementation, but you can run it on a real network or a simulator. The simulator can inject latency, drop packets, partition the cluster, even simulate clock skew β all without modifying a single line of the database code.
4.6 The βKnobsβ System
Every tuning constant in FDB is a knob. They live in
flow/include/flow/Knobs.h
and per-module files like
fdbserver/Knobs.h.
Examples relevant to performance tuning:
| Knob | Default | What it controls |
|---|---|---|
MAX_VERSIONS_IN_FLIGHT | 100,000,000 | MVCC window in versions (~100 s of throughput) |
STORAGE_COMMIT_INTERVAL | 0.005 s | Storage Server batch commit window |
STORAGE_COMMIT_BYTES | 10,000,000 | Force commit if this many bytes buffered |
COMMIT_BATCHES_MEM_BYTES_HARD_LIMIT | 8 GB | Memory cap on uncommitted batches at Proxy |
MAX_READ_TRANSACTION_LIFE_VERSIONS | 5,000,000 | The 5-second read window (versions β 1M/sec) |
RESOLVER_STATE_MEMORY_LIMIT | 1 GB | Conflict-history memory per Resolver |
DESIRED_TOTAL_BYTES_PER_TLOG_QUEUE | 2.4 GB | TLog queue soft cap |
TARGET_BYTES_PER_STORAGE_SERVER | 1 TB | Data Distributorβs per-server target |
Knobs are tweakable at runtime by setting --knob_<NAME>=<VALUE> on the
fdbserver command line. Changing knobs in production is strongly
discouraged β most of them are co-tuned and undocumented in their
interactions. But for performance experiments, knobs are the lever.
4.7 Simulation: How Bugs Get Caught Before You
The simulation harness β fdbserver -r simulation -f some_test.txt β is
how the team finds bugs before customers do.
How a simulation run works
- Boot:
fdbservercreates a synthetic cluster ofnprocesses (typically 5β15), each represented as a coroutine inside the same OS process. - Inject: a
Sim2instance replacesNet2. AllINetworkConnections::net()calls now return simulated connections. - Time: monotonic time is virtual.
delay(0.001)doesnβt sleep β it advances the virtual clock by 1 ms when thereβs nothing else to do. - Workload: a test file (
*.tomlor*.txtintests/) specifies which clients to run (Cycle,RandomTransactionWorkload,ClogTLog, etc.) and which failures to inject (Attrition,Clogging,MachineAttrition). - Fault injection: the simulatorβs RNG (seeded from one 64-bit number) decides at every step whether to kill a process, partition the network, corrupt a disk page, slow a thread.
- Validation: workloads verify their own invariants (
Cyclechecks that a ring of writes traverses correctly). If any actor callsASSERT(...)and it fails, the simulator dumps the seed and exits.
Deterministic replay
Every random decision in the simulator goes through one PRNG, seeded by the
testβs randomSeed. Two runs with the same seed produce identical event
ordering. So when you see:
SimulatorTrace: failed assertion, seed = 1234567890123
β¦you re-run with --random-seed 1234567890123 and reproduce the bug
deterministically β even on a different machine, even months later.
The discipline this imposes is fierce. Anything non-deterministic β system
time, thread scheduling, hash table ordering β is banned from FDB code unless
funneled through g_random. Code review at FDB famously rejects any
std::unordered_map because its iteration order varies across allocator
states. The codebase uses std::map or the Flow-provided ordered containers
instead.
Source pointers
| File | What |
|---|---|
fdbrpc/sim2.actor.cpp | The Sim2 implementation β fake network, fake disk |
fdbserver/SimulatedCluster.actor.cpp | Builds the synthetic cluster topology |
fdbserver/workloads/*.actor.cpp | All workloads β one file per scenario |
tests/ | TOML test definitions β what workload + what failure profile |
A worthwhile read for anyone learning Flow: open
fdbserver/workloads/Cycle.actor.cpp.
It is < 200 lines, uses every Flow primitive, and represents one of the
canonical FDB correctness tests (the βring cycleβ β a sequence of
transactions that should leave a ring of integers in a known configuration).
4.8 Why You Care When You Use FDB from Go
You write Go (or Java, Python, Ruby). You never write Flow. So why does this chapter matter to a layer developer?
Because the client library (libfdb_c.so) β which your Go bindings call
through CGO β is itself written in Flow. Look at
fdbclient/.
Every Database, Transaction, Future object you use in Go is a thin
handle over a Flow object running on a dedicated network thread inside your
process.
This explains several things you might have noticed:
- Thread safety.
fdb.Databaseis goroutine-safe even though its underlying state is mutable. Why? Because every operation eventually queues a callback onto the single Flow network thread. fdb.MustAPIVersion(710). Must be called exactly once per process. This boots the Flow event loop in a background thread. Subsequentfdb.OpenDatabasecalls reuse it.Future.Get()blocking. Your goroutineGet()s a Future. Internally, the C client registers a callback on the Flow Future. When that fires (on the network thread), it signals a condition variable that wakes your goroutine. The hop is: Flow thread β CGO callback β Go scheduler β your goroutine resumes.- The βtransaction wait limitβ. If your goroutine takes seconds between reads, you are not just risking MVCC expiry β you may also be holding network buffers and callback queues open on the Flow side.
Practical implication: keep transactions tight. Compute outside, transact inside. The Go binding is fast, but every CGO call costs ~200 ns and every Future has a Flow-side memory cost.
4.9 Hands-On: Reading One Actor End-to-End
Pick this real example β the function the Proxy calls to assign a commit version:
fdbserver/MasterServer.actor.cpp,
search for ACTOR Future<Void> getVersion(. (In some 7.x branches this is in
SequencerServer.actor.cpp.) The full function is ~60 lines and exercises
nearly every Flow feature:
statevariables for the request batch.loop { ... }for accumulating requests within a 1-ms window.wait(delay(...))to bound batch latency.Promise<...>::send(...)to fulfill each request after assigning versions.try / catch (Error& e)for the standard Flow retry pattern.
If you can read that function in one sitting and explain what it does, you can read 80% of the rest of FDB. The remaining 20% is Coordinator/Paxos and the storage engines β both covered in their own chapters here.
Interview Questions
Q: Why does FoundationDB run on a single thread per process?
To eliminate concurrent-access bugs in the database code and to make the
simulation deterministic. Mutexes, atomics, and thread scheduling are
sources of non-determinism that defeat replayable testing. A single thread
makes every concurrent interaction explicit: itβs a wait() on a Future,
not a hidden interleaving. Scalability across cores is achieved by running
multiple Storage Server, Proxy, etc. processes on the same machine, not by
threading within a process.
Q: What is the difference between wait(f) and f.get()?
wait(f) is legal only inside ACTOR functions. It suspends the current
actor until f is ready, then resumes (without blocking the thread).
f.get() is callable from anywhere but synchronously blocks the thread
until f is ready β which inside Flow is a bug (it would freeze every other
actor). f.get() is only used in test scaffolding and from non-Flow callers
like the C client libraryβs Go binding bridge.
Q: Why are unordered_map and threads banned in FDB code?
Both are sources of non-determinism. unordered_mapβs iteration order depends
on hash-collision history. Threads introduce scheduling-dependent
interleavings. Either breaks the property βrun the simulator with the same
seed twice and get identical execution,β which is the entire basis of FDBβs
testing strategy.
Q: How does the simulation simulate disk I/O?
Sim2βs IAsyncFile implementation backs every file with a std::map of
page bytes in RAM. Writes update the map; reads consult it. Latency is
artificial: a write delays for a synthetic βdisk latencyβ drawn from a
configured distribution. Failures are also synthetic: at random times,
chosen by the seeded PRNG, the simulator can drop pending writes (simulating
a crash before flush) or corrupt a written page (simulating bit rot).
Q: A simulation run finds a bug at seed 0xABCD. The next day, on a new machine, you re-run with the same seed and get a different failure. Whatβs wrong?
Almost certainly someone introduced non-determinism β likely a new
std::unordered_map, a direct gettimeofday(), or a thread-local. Standard
FDB code review pattern: git bisect between the last good build and now,
re-running the failing seed each time. The introduction of non-determinism
is the bug; the original test failure is a symptom of whatever real bug the
simulator was hunting.
Performance β Latency, Throughput, Concurrency in Numbers
- 5.1 Latency β Where Every Millisecond Goes
- 5.2 Throughput β What a Cluster Can Sustain
- 5.3 Concurrency β How Many Transactions Can You Have In Flight?
- 5.4 Transaction Volume β Sizing the Cluster
- 5.5 I/O β Where the Bytes Go
- 5.6 Reproducing the Numbers in This Repo
- 5.7 The Performance Mental Model
- Interview Questions
βFDB is fastβ is not engineering. βA 3-node FDB cluster on c5.4xlarge with Redwood sustains 55,000 cross-shard commits/sec at p99 = 7.8 msβ is engineering.
This chapter gives you concrete numbers, the workloads that produced them, and the model that lets you predict performance for your own workload before you measure it.
All numbers below are from published benchmarks (FoundationDB Summit 2021, Snowflakeβs 2021 Engineering Blog, the official 7.0 release notes, and our own reproduction on the labs in this repo). Treat them as starting points, not contracts.
5.1 Latency β Where Every Millisecond Goes
Anatomy of a single read in a healthy local-datacenter cluster:
Client process GRV Proxy Storage Server
β β β
βββ GetReadVersion ββββββββΊβ β 0.3 ms (RTT/2)
β β batch with peers β 0.1 ms
β β assign version v β 0.0 ms
ββββ version=v βββββββββββββ β 0.3 ms
βββ readValue(key)@v ββββββββββββββββββββββββββΊ β 0.3 ms
β β routing 0.0 ms (cached)
β β MVCC 0.05 ms
β β storage 0.1β10 ms β block cache vs disk
ββββ value βββββββββββββββββββββββββββββββββββββ β 0.3 ms
β
Total: β 1.5 ms (cache hit)
Total: β 6 ms (disk read)
Anatomy of a single commit (small transaction, single shard):
Client β Commit Proxy 0.3 ms
Commit Proxy β Resolvers 0.3 ms β
Resolver conflict check 0.05 ms β done in parallel
Resolvers β Commit Proxy 0.3 ms β
Commit Proxy β TLogs 0.3 ms
TLog fsync + ack 1β4 ms β dominant cost; SSD/NVMe dependent
TLogs β Commit Proxy 0.3 ms
Commit Proxy β Client 0.3 ms
Total: β 3β6 ms (p50) β 8β15 ms (p99 on commodity NVMe)
The TLog fsync is the dominant cost in a commit. This is why FDB
clusters that need low write latency are run on machines with battery-backed
write caches or Intel Optane, where fsync is sub-millisecond.
Latency by operation, real measurements
Measured on the cluster bootstrapβd by this repoβs
docker-compose.yml (single-machine, single
process, Redwood engine, on a 2023 MacBook Pro M2):
| Operation | p50 | p99 | Notes |
|---|---|---|---|
tr.Get(key) (cache hit) | 0.8 ms | 2.1 ms | dominated by CGO + loopback |
tr.Get(key) (cache miss) | 1.5 ms | 4.2 ms | one NVMe page read |
tr.GetRange(100 keys) | 1.2 ms | 3.5 ms | sequential pages, no extra RTTs |
| Empty commit | 2.0 ms | 5.5 ms | TLog fsync |
| 1-key commit | 2.1 ms | 5.6 ms | |
| 100-key commit | 2.4 ms | 6.0 ms | within TLog batch |
| 10,000-key commit (β1 MB) | 12 ms | 35 ms | bounded by network and fsync size |
Multi-node production clusters add 1β3 ms per round trip (cross-rack) or 30β100 ms (cross-region). FDB is firmly a single-region product β cross- region replication is supported but adds the round-trip latency to every commit.
5.2 Throughput β What a Cluster Can Sustain
Single-node (this repoβs setup)
| Workload | Throughput | Notes |
|---|---|---|
| Read (point, cache hit) | ~100,000 ops/s | CGO bound |
| Read (range, 1 KB rows) | ~600 MB/s | network bound (loopback) |
| Commit (empty) | ~5,000 commits/s | TLog fsync rate |
| Commit (10 keys) | ~4,500 commits/s | barely affected by transaction size |
| Set (sustained, in batches of 1,000) | ~150,000 sets/s | TLog throughput |
Production-shape cluster (3 Γ c5.9xlarge, single AZ, double replication)
Published by the FDB Summit 2021 talk βFoundationDB at Snowflakeβ:
| Workload | Throughput |
|---|---|
| Mixed 80/20 read/write small KV | 1.4 M ops/s |
| Pure read (point, cache hit) | 4.0 M ops/s |
| Pure commit (single-key) | 220 K commits/s |
| Bulk load (1 MB transactions) | 1.1 GB/s ingest |
Scaling rules of thumb
- Read throughput scales linearly with Storage Server count, until you saturate either the network or the GRV Proxy.
- Commit throughput scales sub-linearly with TLog count. Each TLog handles ~50β80 K commits/s. Adding TLogs helps until the Commit Proxy becomes the bottleneck (~250 K commits/s per Commit Proxy).
- Adding Commit Proxies shards commit traffic by key range and scales almost linearly until the Resolver becomes saturated. A Resolver handles ~500 K conflict-resolution ops/s.
If your workload exceeds these single-process limits, the answer is add more of the bottleneck process, not a bigger machine. FDB scales out, not up.
5.3 Concurrency β How Many Transactions Can You Have In Flight?
The fundamental concurrency parameter is the MVCC window β the number of versions kept readable. Default β 5 seconds Γ ~1 M versions/sec = 5 M.
This sets:
- Maximum concurrent transactions. Each transaction holds a read version. With β 1 M versions/sec issued by GRV Proxies, holding 5 M versions means you can have about 5 M in-flight transactions β though in practice the per-Resolver memory limit binds first.
- Maximum transaction duration. Same 5 seconds, end-to-end.
- Resolver memory. Holds recent write-key sets. With 250 K commits/s Γ 5 s
= 1.25 M commits, each with ~10 keys, thatβs 12.5 M keys Γ ~64 B per key =
800 MB. Hence the 1 GB
RESOLVER_STATE_MEMORY_LIMITknob.
Conflict rate as a function of concurrency
The classic OCC formula: if N transactions run concurrently, each touching
k keys uniformly at random from a key space of size M, the per-transaction
conflict probability is approximately:
$$ P_{\text{conflict}} \approx 1 - \left(1 - \frac{k}{M}\right)^{N \cdot k} $$
For k = 10, M = 1,000,000, the conflict rate by N:
| N (concurrent txns) | Approx conflict rate |
|---|---|
| 10 | 0.1% |
| 100 | 1.0% |
| 1,000 | 9.5% |
| 10,000 | 63% |
This is why hot key contention dominates real-world FDB performance.
A hot key effectively shrinks M to 1 for those transactions, and the
conflict rate goes to ~100%. The fix is almost always sharding by client ID
or time, transforming (counter) into (counter, clientID % 64) and
summing on read.
How to reason about concurrency in your layer
Two questions to answer for every layer you build:
- What is the largest natural key prefix shared by transactions? If two
transactions both write
user:42:*, they conflict on the implicit βuser 42β read of the schema row. Split the schema reads from the data writes. - What is your atomic-op opportunity rate? Anywhere you do
βread-modify-writeβ of a counter, replace it with
Add(). This removes the read from the read-conflict set entirely β atomic ops never conflict.
5.4 Transaction Volume β Sizing the Cluster
Capacity planning starts with the bottleneck. For each FDB component:
| Component | Limiting metric | Typical ceiling per process |
|---|---|---|
| Commit Proxy | CPU + RPC fan-out | 250 K commits/s |
| GRV Proxy | RPC throughput | 1 M GetReadVersion/s |
| Resolver | CPU (conflict check) | 500 K commits/s |
| TLog | fsync rate Γ batch size | 50β80 K commits/s, 200 MB/s write |
| Storage Server | NVMe write IOPS / read CPU | 100 K reads/s, 250 MB/s write |
Worked example. You need to sustain 100 K commits/s Γ 50 keys per commit (= 5 M sets/s), 500 K reads/s, with 10 TB total storage.
- Commit Proxies: 100 K / 250 K = 1; round up to 2 for HA. β
- TLogs: 100 K / 50 K = 2; round up to 4 for replication. β
- Resolvers: 100 K / 500 K = 1. β
- Storage Servers: 5 M sets/s Γ· (β100 K writes/s per server) = 50. Cross-check: 10 TB Γ· (β200 GB/server target) = 50. β β
- Coordinators: 3 (or 5) for Paxos. Standard.
Total: 50 storage servers + 2 commit proxies + 1 GRV proxy + 1 resolver + 4 TLogs + 3 coordinators = 61 roles. Pack them onto ~20 machines (multiple roles per machine is fine, except donβt co-locate TLog and Storage Server unless you have separate disks for each β they will compete for fsync).
This calculation is exactly what the fdbcli> configure command lets you
declare: configure new double ssd commit_proxies=2 grv_proxies=1 resolvers=1 logs=4.
5.5 I/O β Where the Bytes Go
For a single commit of B bytes of writes on a triple-replicated cluster
with one Resolver:
| Disk write origin | Bytes written | Why |
|---|---|---|
| TLog (the WAL) | 3 Γ B | replication factor 3 |
| Storage Server (durable apply) | 1.5 Γ B | RF 3 Γ· 2 shard-team size, plus engine overhead |
| Redwood B+tree page rewrites | ~1.5 Γ B | copy-on-write parent pages |
| Total write amplification | β 5β7Γ | typical Redwood deployment |
This is much better than RocksDBβs 10β30Γ and slightly worse than naive in-place B-tree (which would be β 4Γ but loses durability + concurrency). On a write- heavy workload pushing 100 MB/s of application writes, the clusterβs aggregate disk write rate is β 600 MB/s β plan SSD endurance accordingly.
For reads with cold cache, every read is one B+tree path: 3β5 page reads of 4β32 KB each = 12β160 KB of disk reads per logical key, served from the Storage Serverβs page cache when hot.
5.6 Reproducing the Numbers in This Repo
Use the lab clusters to measure your local equivalent:
# Spin up FDB
docker compose up -d
./scripts/bootstrap-fdb.sh
# Quick latency probe using fdbcli
docker exec -it $(docker ps -qf name=fdb) bash -lc \
"echo -e 'option on TIMEOUT 5000\nget x' | fdbcli"
# Throughput: use option-a-leveldb's batch demo (writes 100k keys)
cd option-a-leveldb && time go run ./demo
To get production-shape numbers, run the official benchmark:
# Inside the FDB container:
fdbcli> configure new double ssd
mako --mode build --cluster_file /etc/foundationdb/fdb.cluster \
--rows 1000000 --commits 50 --keys_per_xact 10
mako --mode run --cluster_file /etc/foundationdb/fdb.cluster \
--rows 1000000 --threads 32 --duration 60 --tps 100000
mako is FoundationDBβs official load generator (in
bindings/c/test/mako/).
Its output gives per-operation latency histograms, conflict rate, retry rate,
and aggregate throughput β the same toolchain the FDB team uses for releases.
5.7 The Performance Mental Model
Internalize these and you can ballpark almost any FDB workload:
- Reads are cheap. ~1 ms p50, scale linearly with Storage Servers. If reads are your bottleneck, add Storage Servers.
- Commits cost one
fsync. ~3 ms p50, scale linearly with TLogs. If commits are your bottleneck, add TLogs (and check that youβre not hitting a Resolver / Commit Proxy ceiling first). - Conflicts are quadratic in concurrency. Halve hot-key access, halve conflict rate^2 (because both the writer and the readerβs odds drop).
- Write amplification is ~5Γ. Plan SSD endurance and bandwidth from that, not from application MB/s.
- The 5-second MVCC window bounds transaction duration. Long-running work must be chunked into bounded transactions.
- There is no cross-region magic. Cross-region adds RTT to every commit. Use a single region with multi-DC zoning for HA, not for low latency.
Interview Questions
Q: A production FDB cluster has p50 commit latency of 4 ms but p99 of 80 ms. What are the likely causes?
The p99 outliers almost always trace to (a) TLog fsync stalls on a contended
disk β check disk service times and whether TLog and Storage Server share
disks; (b) conflict-retry cascades, where a contested hot key forces many
transactions into multiple retries β check the clusterβs conflict_rate and
retry_rate metrics; or (c) shard movement triggering brief read or commit
queueing β check Data Distributor activity. The fact that p50 is good rules
out a sustained capacity problem; itβs an episodic stall.
Q: How would you measure read amplification of a specific layer?
Run the layer with FDBβs per-transaction stats enabled
(tr.GetEstimatedRangeSize(...) and tr.GetApproximateSize() give you read
byte and write byte counts). Sum reads-per-user-request and compare to the
size of the user-visible result. For an index-heavy schema the ratio is
typically 2β4Γ (one read for the index, one for the row, plus schema
lookups). If itβs > 10Γ, you have a missing index or schema-cache
opportunity.
Q: A new shard split happens every few seconds during a bulk load. Why, and how do you mitigate?
Default shard target size is ~250 MB. A bulk load that writes contiguously fills shards quickly, and the Data Distributor splits them. Each split copies data to a new Storage Server, briefly degrading throughput. Mitigation: randomize the prefix of bulk-loaded keys (e.g., prefix with a hash) so writes spread across many existing shards instead of growing one shard linearly. Alternatively, pre-split: write sentinel keys to force the Data Distributor to pre-create shards at known boundaries before the bulk load begins.
Q: Your application sees consistent 500 K reads/s but commits cap at 40 K/s. Cluster has 1 Commit Proxy, 1 Resolver, 2 TLogs, 12 Storage Servers. What do you change?
40 K commits/s is roughly the per-TLog ceiling Γ 1 (the slower TLog is the
bottleneck of replicated commits). Add 2 more TLogs (logs=4). If commits
still donβt scale, the Commit Proxy is next (250 K/s ceiling, but it spends
RPC bandwidth fanning out to TLogs, so the practical ceiling is lower); add
a second Commit Proxy. Reads are fine because they bypass the commit path
entirely and hit Storage Servers directly.
Q: You need to support a 30-second analytical scan over 100 GB of data. FDBβs MVCC window is 5 seconds. Whatβs the pattern?
Donβt. Break the scan into ~1-second chunks, each its own transaction,
each resuming from the last key of the previous chunk
(tr.GetRange(lastKey+\x00, end)). Accept that you no longer have a
consistent snapshot across chunks β if that matters, snapshot the data to a
side store (object storage) first and analyze the snapshot. For
production-grade analytics over FDB data, the canonical pattern is to
stream changes (via versionstamped change-feed keys) into a downstream
columnar store like ClickHouse or BigQuery.
The Layer Concept
- 3.1 Encoding Is Everything
- 3.2 The Subspace Pattern
- 3.3 The Tuple Layer β Industry Standard Encoding
- 3.4 Atomicity as a Design Axiom
- 3.5 Key Space Design Patterns and Anti-Patterns
- 3.6 The FDB Blob Layer Pattern
- 3.7 Schema Evolution β How Layers Survive Change
- Interview Questions
The layer concept is the central architectural idea of FoundationDB. FDBβs API surface is tiny: Get, Set, Clear, ClearRange, GetRange, and transactions. All the richness of a βdatabaseβ β tables, indexes, files, records, schemas β must be built from these six primitives. This chapter explains exactly how.
Want the production-grade example? The next chapter, The Record Layer β A Deep Dive, pulls Appleβs open-source
fdb-record-layerapart end-to-end: tuple layer, directory layer,FDBRecordStore, the planner, and a byte-by-byte walk-through of onesaveRecord()call.
3.1 Encoding Is Everything
FDB stores byte strings. There is no concept of integer columns, string columns, or JSON fields at the FDB level. You must encode your data into bytes and decode it back out. The art of building a layer is entirely the art of encoding β turning rich data structures into sequences of (key, value) pairs such that:
- Unambiguous decoding: given a raw key-value pair, you can reconstruct the original data
- Co-location: related data is adjacent in key order, enabling efficient range scans
- Minimal write amplification: updating one piece of data doesnβt force rewrites of unrelated data
- Sort-preserving encoding: values you want to range-query sort correctly under byte comparison
All four properties must hold simultaneously. Letβs examine each.
Property 1: Unambiguous Decoding
Suppose you store two fields, name and city, in a single key:
key: "user:" + name + ":" + city
If name = "Alice:Paris" and city = "London", the key becomes "user:Alice:Paris:London", which looks identical to name = "Alice", city = "Paris:London". Ambiguous. Solution: choose a separator that cannot appear in field values, or escape it.
FDBβs Tuple layer solves this by escaping \x00 inside strings as \x00\xFF, then using bare \x00 as a separator. This makes encoding unambiguous for arbitrary byte strings.
Property 2: Co-location
Imagine storing a userβs profile fields as separate keys:
"user:alice:name" β "Alice"
"user:alice:email" β "alice@example.com"
"user:alice:city" β "Paris"
"user:bob:name" β "Bob"
Reading Aliceβs full profile = one GetRange("user:alice:", "user:alice\x01") call. All three keys are adjacent in FDBβs sorted key space. One round-trip, no matter how many fields she has.
Contrast with storing all users in one key: "users" β msgpack([alice, bob, ...]). Reading one user requires reading the entire blob. Updating one user requires reading the entire blob, deserializing, modifying, re-serializing, and writing back. Terrible co-location.
Co-location means: keys for the same logical entity should share a prefix, so they sort together and can be read or deleted in one range operation.
Property 3: Minimal Write Amplification
Consider a user with 50 indexed fields. If a user moves from Paris to Tokyo, only two keys change: the old city index entry (cleared) and the new city index entry (set). The userβs record and all other index entries are untouched.
Designing your encoding so that βupdate one fieldβ touches only the keys for that field is the goal. If your encoding stores the entire record in one value, every field update touches the entire value β wasteful when records are large.
Property 4: Sort-Preserving Encoding
If you want to range-query age BETWEEN 25 AND 40, the encoded age values must sort in the same order as the numeric values.
String encoding fails for integers:
"9" > "30" (byte comparison: '9' = 0x39 > '3' = 0x33)
β sorted order: "1", "10", "100", "2", "20", "3", "30", "9" β wrong
Signed big-endian fails across sign boundary:
-1 β 0xFFFFFFFFFFFFFFFF (sorts AFTER all positive numbers)
The correct encoding (used in every layer in this repo):
// encoding.go (option-c-record-layer)
func encodeInt64(x int64) []byte {
b := make([]byte, 8)
// Flip the sign bit. Maps signed range to unsigned range preserving order.
// -2^63 β 0x0000...0000 (smallest unsigned)
// -1 β 0x7FFF...FFFF
// 0 β 0x8000...0000
// 1 β 0x8000...0001
// 2^63-1 β 0xFFFF...FFFF (largest unsigned)
binary.BigEndian.PutUint64(b, uint64(x)^(1<<63))
return b
}
This encoding is the industry standard for sort-preserving signed integer encoding. It appears in Apache HBase, Apache Cassandra, FDBβs own Tuple layer, and Google Bigtable.
Why XOR with 1<<63 is the same as adding 2^63: For any signed 64-bit integer x, uint64(x) XOR (1<<63) == uint64(x) + 2^63 when computed in 64-bit unsigned arithmetic. XOR is just the bit-manipulation shorthand for the offset encoding.
3.2 The Subspace Pattern
The single most important pattern in any FDB layer is the subspace: a byte prefix that namespaces all keys for a logical entity.
// encoding.go (option-a-leveldb)
type Subspace struct {
prefix []byte
}
func (s Subspace) Pack(userKey []byte) fdb.Key {
out := make([]byte, 0, len(s.prefix)+1+len(userKey))
out = append(out, s.prefix...)
out = append(out, 0x00) // separator byte
out = append(out, userKey...)
return fdb.Key(out)
}
func (s Subspace) Range() fdb.KeyRange {
begin := append([]byte{}, s.prefix...)
begin = append(begin, 0x00)
end := append([]byte{}, s.prefix...)
end = append(end, 0x01) // one byte past the separator
return fdb.KeyRange{Begin: fdb.Key(begin), End: fdb.Key(end)}
}
Why the separator byte?
Without a separator, subspaces "foo" and "foobar" collide: the key "foobar\x00somekey" has "foo" as a prefix but was packed by the "foobar" subspace. The separator \x00 prevents this: "foo\x00" is never a prefix of "foobar\x00" because their 4th bytes differ (\x00 vs b).
The range trick β \x00 β \x01:
Incrementing the separator byte from \x00 to \x01 creates a tight upper bound. Every key in the "demo\x00" subspace sorts before "demo\x01". So GetRange("demo\x00", "demo\x01") returns exactly the keys for the βdemoβ namespace β no more, no less.
Subspace composition (nested subspaces):
You can build hierarchical key spaces by composing prefixes:
users subspace: "app\x00users\x00"
orders subspace: "app\x00orders\x00"
indexes subspace: "app\x00idx\x00"
city index: "app\x00idx\x00city\x00"
value "Paris": "app\x00idx\x00city\x00Paris\x00"
Each prefix cleanly separates a logical domain. ClearRange("app\x00users\x00", "app\x00users\x01") atomically deletes all users. ClearRange("app\x00idx\x00city\x00Paris\x00", "app\x00idx\x00city\x00Paris\x01") atomically deletes all city=βParisβ index entries.
All five layers in this repo use this pattern:
| Layer | Subspace layout |
|---|---|
| option-a-leveldb | ns + 0x00 + userKey |
| option-a-sqlite | ns + 0x00 + tableName (catalog), ns + 0x01 + tableName + 0x00 + pk (rows) |
| option-b-leveldb | ns + 0x01 + fileType + fileNum (meta), ns + 0x02 + fileType + fileNum + chunkNum (data) |
| option-b-sqlite | ns + 0x00 (size key), ns + 0x01 + pageNum (pages) |
| option-c-record-layer | ns + 0x00 + schema + 0x00 + pk (records), ns + 0x01 + schema + 0x00 + field + 0x00 + value + 0x00 + pk (indexes) |
3.3 The Tuple Layer β Industry Standard Encoding
The official FDB client libraries (Python, Java, Go, Ruby) provide a Tuple encoding that standardizes the above patterns. It encodes lists of typed values into byte strings that sort correctly under FDBβs lexicographic comparison.
Tuple type codes and sort behavior:
| Type | Prefix byte | Encoding | Sort behavior |
|---|---|---|---|
None / nil | 0x00 | just the prefix | always smallest |
| Byte string | 0x01 | bytes + \x00 (with \x00 β \x00\xFF) | lexicographic |
| Unicode string | 0x02 | UTF-8 + \x00 (same escaping) | lexicographic |
| Nested tuple | 0x05 | nested encoding + \x00 | element-by-element |
| Integer 0 | 0x14 | just the prefix | numeric zero |
| Positive integer | 0x15β0x1C | 1β8 bytes big-endian | numerically ascending |
| Negative integer | 0x0Cβ0x13 | 1β8 bytes, bitwise NOT | numerically ascending |
| Float (32-bit) | 0x20 | 4 bytes, sign-bit-flipped IEEE 754 | numerically ascending |
| Double (64-bit) | 0x21 | 8 bytes, sign-bit-flipped IEEE 754 | numerically ascending |
| Boolean false | 0x26 | just the prefix | false < true |
| Boolean true | 0x27 | just the prefix | false < true |
| UUID | 0x30 | 16 bytes | UUID byte order |
| Versionstamp | 0x32/0x33 | 12 bytes | commit version ordering |
Why the layers in this repo donβt use the Tuple layer:
Using the official Tuple layer would be correct and production-appropriate. The layers here use simpler hand-rolled encodings so that each file is readable without understanding the Tuple specification. When reading the source, you can see append(out, 0x00) instead of tuple.Pack(tuple.Tuple{schema, pk}) β the intent is obvious.
For production use, adopt the Tuple layer. It handles edge cases (null bytes in strings, arbitrarily large integers, nested structures) that the hand-rolled encodings in this repo skip.
3.4 Atomicity as a Design Axiom
The most important design discipline in FDB layer development: every logical operation that must be atomic must live inside one transaction.
This sounds obvious but is violated constantly by developers who treat FDB as a fancy cache. Letβs examine the consequences of each atomicity violation:
Case 1: Split Insert (SQL Layer)
// WRONG β two transactions for one logical insert
rowid := allocateRowid() // Transaction 1: increment counter
writeRow(schema, rowid, values) // Transaction 2: write row data
// If crash between T1 and T2:
// β counter incremented, row never written
// β counter is now permanently incorrect (rowid was "consumed")
// β future inserts skip this rowid
The correct approach (used in option-a-sqlite):
// RIGHT β one transaction
db.Transact(func(tr fdb.Transaction) (interface{}, error) {
// Read and increment rowid counter
rowid := tr.Get(rowidKey).Get() + 1
tr.Set(rowidKey, encode(rowid))
// Write the row
tr.Set(rowKey(schema, rowid), encode(values))
return nil, nil
})
Case 2: Split Index Update (Record Layer)
// WRONG β separate transactions for record and index
tr1.Set(recordKey, newRecord) // T1: update record
tr2.Clear(oldIndexKey) // T2: remove old index
tr2.Set(newIndexKey, nil) // T2: add new index
// If crash between T1 and T2:
// β record shows new value
// β index still points to old value
// β LookupByIndex returns stale data forever
// β This is a phantom index entry β silent corruption
The correct approach (used in option-c-record-layer):
// RIGHT β all in one transaction
s.fdb.Transact(func(tr fdb.Transaction) (interface{}, error) {
// Read old record to know what indexes to remove
prevBytes, _ := tr.Get(recordKey(s.ns, schema, pk)).Get()
if prevBytes != nil {
var prev Record
msgpackUnmarshal(prevBytes, &prev)
for _, f := range sc.Indexes {
if v, ok := prev[f]; ok {
tr.Clear(indexKey(s.ns, schema, f, v, pk))
}
}
}
// Write new record and new indexes
tr.Set(recordKey(s.ns, schema, pk), encoded)
for _, f := range sc.Indexes {
if v, ok := rec[f]; ok {
tr.Set(indexKey(s.ns, schema, f, v, pk), nil)
}
}
return nil, nil
})
Case 3: Split File Rename (LevelDB Storage Layer)
// WRONG β separate transactions for copy and delete
copyFileData(newFd, oldFd) // T1: copy all chunks
deleteFile(oldFd) // T2: clear old chunks
// If crash between T1 and T2:
// β both files exist simultaneously
// β LevelDB manifest references old name
// β on restart, LevelDB sees duplicate SSTables β corruption
The correct approach (used in option-b-leveldb):
// RIGHT β one transaction for atomic rename
s.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
kvs, _ := tr.GetRange(s.dataRange(oldfd), ...).GetSliceWithError()
tr.ClearRange(s.dataRange(oldfd)) // clear old
tr.Clear(s.metaKey(oldfd))
for _, kv := range kvs { // write new
tr.Set(translateKey(kv.Key, oldfd, newfd), kv.Value)
}
tr.Set(s.metaKey(newfd), metaBytes)
return nil, nil
})
The pattern: read old state β clear old keys β write new keys β in one transaction. Crash at any point: transaction not committed, neither state is visible. Crash after commit: new state is fully visible. No intermediate state is ever observable.
3.5 Key Space Design Patterns and Anti-Patterns
Pattern: Sequential Integer Keys
key: ns + 0x00 + encode_uint64(rowid)
Values: row data. Row id is monotonically increasing. New rows always insert at the end of the range (append-like behavior in the key space). Range scans return rows in insertion order.
Production consideration: In a write-heavy workload, all inserts land on the same key space boundary, creating a hotspot on the FDB shard holding the highest rowid. FDB will split this shard, but the split lags the write rate. For very high insert rates, use a randomized prefix (hash of rowid) to distribute writes across shards.
Pattern: Reverse Timestamp Keys
key: ns + 0x00 + encode_uint64(MaxUint64 - timestamp)
Most recent entries sort first. A GetRange with no bounds returns entries in reverse chronological order. Useful for βrecent eventsβ, βactivity feedsβ, βaudit logsβ.
Pattern: Composite Keys for Multi-Dimensional Queries
key: ns + encode(field1) + 0x00 + encode(field2) + 0x00 + pk
Example: city + 0x00 + age + 0x00 + pk enables range queries on city AND age:
- All users in Paris sorted by age:
GetRange(prefix + "Paris\x00", prefix + "Paris\x01") - All users in Paris aged 25β40:
GetRange(prefix + "Paris\x00" + encode(25), prefix + "Paris\x00" + encode(40) + "\xff")
This is the compound index pattern. It is exactly how MySQLβs compound indexes work.
Anti-Pattern: Large Values
Storing all of a userβs data in one 50 KB value:
key: ns + pk
value: msgpack({name, email, all_orders: [...], all_addresses: [...], ...})
Problems:
- Reading one field requires reading and deserializing the entire value (O(size))
- Updating one field rewrites the entire value (O(size) write amplification)
- The value may approach FDBβs 100 KB limit
- Hot users (many orders) create unbalanced value sizes
Better: normalize into separate keys per logical entity:
ns + 0x00 + pk β profile (name, email)
ns + 0x01 + pk + orderid β individual orders
ns + 0x02 + pk + addrid β individual addresses
Anti-Pattern: Hot Key Contention
key: "global_counter" β every write touches this
All transactions that increment a counter conflict on this key. At high throughput, this becomes a serialization bottleneck.
Solutions:
- FDB atomic ops:
tr.Add("global_counter", 1)β atomic, no conflict, no retry needed - Sharded counters: split into N shard keys; read by summing all shards
- Approximate counting: use a HyperLogLog or count-min sketch in a single key
3.6 The FDB Blob Layer Pattern
FDBβs value size limit (100 KB) is not a fundamental limitation β itβs a performance tuning decision. The solution is chunking, used in option-b-leveldb:
key: ns + 0x01 + fileType + fileNum + chunkNum β up to 65,536 bytes
To read a file: range-scan all chunks (sorted by chunkNum due to big-endian encoding), concatenate values.
This pattern generalizes to any large object:
- Files: split by byte range (64 KB chunks)
- Images: split by byte range or by tile
- Blobs: split by arbitrary chunk size
- Large JSON documents: split by logical sub-object
FDBβs official Blob Layer (in the FDB foundationdb-python-layers repository) implements this exact pattern. Amazon S3 and Google Cloud Storageβs consistency guarantees are actually powered by similar chunk-based storage backed by distributed KV stores.
3.7 Schema Evolution β How Layers Survive Change
A layerβs encoding is its schema. Changing the encoding without migrating old data is a common source of production incidents. Strategies:
1. Prefix-versioned keys:
ns + version_byte + ...rest
New code writes version = 0x02 keys; old code writes version = 0x01 keys. A background migration reads all 0x01 keys, converts, writes 0x02 keys. Read code handles both versions.
2. Additive-only changes: If your value is msgpack/Protobuf, adding new optional fields is backward compatible. Old code ignores unknown fields. New code sets defaults for missing fields. This is safe and zero-downtime.
3. Dual writes during migration: Write to old AND new key format simultaneously during a migration window. Verify correctness. Backfill old data. Cut over to new format. Remove old code path. Zero-downtime migration.
4. Never reuse key prefixes: Deleted keys leave no trace in FDB (thereβs no tombstone value). But if you reuse a deleted subspaceβs prefix for a new purpose, you risk stale data colliding with new data. Always use new prefixes for new logical entities.
Interview Questions
Q: Why is key ordering so important in FDB β canβt you just use a hash map?
A hash map gives O(1) point lookups but cannot efficiently answer range queries (βall users aged 25β40β), prefix queries (βall keys starting with βuser:42:ββ), or sorted iteration. Ordered storage enables all three with a single range scan. The entire record layer concept β where secondary indexes are just cleverly prefixed keys β relies on ordering. Without ordering, every query would require a full scan.
Q: What is a βsubspaceβ in FDB and why is it important?
A subspace is a key prefix that namespaces a set of keys. It provides namespace isolation (keys in subspace A cannot collide with keys in subspace B), atomic deletion (one ClearRange deletes all keys in a subspace), and efficient range queries (one GetRange over the subspace prefix reads all related data). Every production FDB layer uses subspaces as the fundamental organizational unit.
Q: How do you handle null bytes inside keys in FDB?
FDB keys can contain any bytes, but if you use \x00 as a separator, null bytes inside field values create ambiguity. The official solution is the Tuple encoding: escape \x00 inside string values as \x00\xFF, and use bare \x00 as the separator. The decoder sees \x00\xFF and knows itβs an escaped null; bare \x00 ends the field. The layers in this repo use simple encodings that assume field values donβt contain null bytes β acceptable for a teaching implementation, not for production with arbitrary user data.
Q: Why is the PutRecord function in the record layer more expensive than a simple Set?
PutRecord must read the old record before writing the new one, so it can remove stale index entries. This adds one Get call to every write β a full round-trip to a Storage Server. The cost is necessary: without reading the old record, you canβt know which old index entries to remove. Production systems amortize this by batching multiple record updates in one transaction, or by using techniques like βshadow documentsβ (keeping old field values in the record itself to avoid the read).
Q: What is the difference between Transact and ReadTransact in FDB?
ReadTransact opens a read-only transaction: no conflict tracking, no commit round-trip, cheaper. Itβs for operations that only read data. Transact opens a read-write transaction: tracks the read set for conflict detection, buffers writes, commits at the end. Use ReadTransact whenever youβre not writing anything β itβs faster and imposes less load on the cluster.
The FoundationDB Record Layer β A Deep Dive
- 7.1 Why a Record Layer?
- 7.2 The Layered Architecture
- 7.3 Tuple Layer β Order-Preserving Encoding
- 7.4 Subspaces and the Directory Layer
- 7.5 The Record Layer Core β
FDBRecordStore - 7.6 Index Types in Detail
- 7.7 Concurrency, Conflicts, and the 5-Second Rule
- 7.8 How This Maps to the Toy Go Implementation
- 7.9 When Should You Use the Record Layer?
- 7.10 Reading Order for the Source
- 7.11 Interview Questions
The previous chapter taught you that a layer is just an encoding of a data model onto FDBβs ordered byte-string KV interface. This chapter takes the most production-grade example of that idea β Appleβs open-source foundationdb/fdb-record-layer β and pulls it apart end-to-end.
By the time you finish you should be able to:
- Explain, on a whiteboard, how a Record Layer
saveRecord()call becomes a set of FDB key-value mutations. - Read the upstream Java source and recognize the major subsystems.
- Decide when to use the Record Layer instead of writing your own layer.
- Map every concept back to the toy Go reimplementation in this repo (option-c-record-layer) so you can experiment in 100 lines of Go before touching the 200k-line Java codebase.
Reference revisions. Code references in this chapter point at the
mainbranch ofFoundationDB/fdb-record-layerand FDBrelease-7.3. Specific class names and file paths are stable across recent versions.
7.1 Why a Record Layer?
FDB itself gives you six operations: get, set, clear, clearRange,
getRange, and a transaction wrapper. Thatβs a beautifully small surface,
but if you build an application directly on it you will, within a week,
have written:
- A primary-key encoder.
- A secondary-index maintainer.
- A schema-evolution story for when you add a field.
- A query planner that picks between scanning by primary key and scanning by index.
- Pagination with cursors that survive transaction boundaries.
- Type-safe Java/Kotlin/Swift bindings instead of raw byte arrays.
Every team that builds on FDB writes the same eight thousand lines of plumbing. Apple wrote them once, hardened them with CloudKit (production since 2015, billions of users), open-sourced them in 2018, and called them the Record Layer.
So a Record Layer is a layer on top of FDB that gives you:
| Capability | What it replaces |
|---|---|
RecordStore.saveRecord(message) | Hand-rolled tuple encoding |
| Protobuf-defined schemas | map[string]any or ad-hoc structs |
| Declarative indexes (value, rank, version, text, aggregate) | Hand-maintained index keys |
Cost-based query planner (RecordQueryPlanner) | βWhich prefix do I scan?β by hand |
RecordCursor<T> with continuations | Pagination glue code |
| Online index builds | Downtime to add an index |
Multi-tenancy via KeySpacePath | Stringly-typed prefixes |
The price is a Java dependency and a sizable conceptual surface. The rest of this chapter is that surface.
7.2 The Layered Architecture
The Record Layer is itself a stack of three layers above FDB:
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your application β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β RecordStore (schema + index + query API) β β fdb-record-layer-core
ββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Subspace / Tuple / Directory layer β β fdb-extensions, Java tuple layer
ββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β FDB transactions (get/set/clear/getRange) β β libfdb_java β libfdb_c
ββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β FoundationDB cluster β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
Each of the three layers above FDB has a distinct responsibility:
- Tuple layer β order-preserving binary encoding of typed tuples.
Source:
com.apple.foundationdb.tuple.Tuplein the Java bindings. - Subspace / Directory layer β namespace prefixes that turn one giant
keyspace into a hierarchy of sub-keyspaces. Source:
com.apple.foundationdb.subspace.Subspace,com.apple.foundationdb.directory.DirectoryLayer. - Record Layer core β protobuf-typed records, indexes, queries.
Source:
com.apple.foundationdb.record.provider.foundationdb.FDBRecordStore.
We climb each in turn.
7.3 Tuple Layer β Order-Preserving Encoding
The tuple layerβs job is to convert a typed sequence like
("users", 42L, "alice") into a byte string whose lexicographic byte
order matches the natural sort order of the original tuple. This is the
hinge that everything else swings on, because FDB sorts keys by raw bytes.
The wire format uses a one-byte type tag per element, then the value:
| Type tag | Meaning | Notes |
|---|---|---|
0x00 | null | |
0x01 | byte string | terminated 0x00, internal 0x00 escaped to 0x00 0xFF |
0x02 | UTF-8 string | same escaping |
0x05 | nested tuple | recursive encoding |
0x0Bβ0x1D | integer | length-prefixed big-endian, with a clever sign trick |
0x20 | IEEE 754 float | sign bit flipped + twoβs-complement for negatives |
0x21 | IEEE 754 double | same trick |
0x26 / 0x27 | false / true | |
0x30 | UUID | |
0x33 | Versionstamp | 80-bit, atomic-op rewritten at commit |
The integer encoding is the cleverest bit. To make a 64-bit signed integer
sort-correctly under byte comparison without using a fixed 8-byte width
(which would waste space), the tuple layer uses 13 different type codes
(0x0B through 0x1D) representing lengths from -8 bytes (very negative)
to +8 bytes (very large positive), and the value bytes are written in
big-endian with a complement for negatives. Decoding is just the inverse;
encoding 42 takes 2 bytes total.
Read the source: com.apple.foundationdb.tuple.TupleUtil in
bindings/java/src/main/com/apple/foundationdb/tuple/TupleUtil.java. The
encoding rules are codified there and copied β bit for bit β in every
official FDB binding so that a Go process and a Java process can interop.
Worked example
byte[] k = Tuple.from("users", 42L, "alice").pack();
// k = 02 75 73 65 72 73 00 β "users"\0
// 15 2A β int tag 0x15 = 1 byte positive, value 0x2A = 42
// 02 61 6C 69 63 65 00 β "alice"\0
Now Tuple.from("users", 42L, "bob").pack() is byte-greater than the above
because "bob" > "alice" lexicographically, and
Tuple.from("users", 43L).pack() is byte-greater again because the integer
prefix differs. Co-location and ordering come for free.
The Go toy version in this repo, encoding.go, sidesteps the full tuple encoding by using fixed
0x00separators and assuming string fields donβt contain nulls. Thatβs fine for a demo, but the production tuple layer handles arbitrary bytes correctly.
7.4 Subspaces and the Directory Layer
A Subspace is just a tuple prefix plus convenience methods:
Subspace users = new Subspace(Tuple.from("app", "users"));
byte[] key = users.pack(Tuple.from(42L, "alice"));
// equivalent to Tuple.from("app","users",42L,"alice").pack()
Range range = users.range();
// β from "app/users/\x00" to "app/users/\xFF"
This is com.apple.foundationdb.subspace.Subspace β a few hundred lines.
The Directory Layer is the next step up. Hard-coding "app/users" as
a prefix in production is wasteful (itβs repeated millions of times in
keys). The directory layer maps human-readable paths to short integer
prefixes:
DirectoryLayer dir = DirectoryLayer.getDefault();
DirectorySubspace usersDir = dir.createOrOpen(tr, List.of("app", "users")).join();
// usersDir.getKey() might be \x15\x0C β a 2-byte allocated prefix
Underneath, the directory layer stores its mapping in a well-known
subspace (\xFE) and allocates new short prefixes using a small
high-contention allocator (HighContentionAllocator) that uses random
partitioning to avoid hot-spotting. The allocator is itself an instructive
chunk of source: com.apple.foundationdb.directory.HighContentionAllocator.
This matters because every Record Layer store lives in a directory. A
multi-tenant Record-Layer-based service will typically have a
KeySpacePath of the form
/applications/{appId}/environments/{env}/recordStores/{storeId}, each
piece resolved through the directory layer into a tiny integer prefix.
Adding a tenant is one transaction; deleting one is one
Transaction.clear(Range).
7.5 The Record Layer Core β FDBRecordStore
This is where it gets interesting. The central class is
com.apple.foundationdb.record.provider.foundationdb.FDBRecordStore. A
record store is bound to:
- a
Subspace(its root prefix, usually from the directory layer), - a
RecordMetaData(the schemas of the records it can hold), - an open FDB transaction (record stores are short-lived, one-per-transaction objects).
The schema is protobuf
Schemas are .proto files. The Record Layer ships a small Protobuf
extension package that adds annotations:
import "record_metadata_options.proto";
message User {
required int64 id = 1 [(field).primary_key = true];
required string name = 2;
optional string email = 3 [(field).index = { type: "value" }];
optional int64 signup_ts = 4;
}
message RecordTypeUnion {
optional User _User = 1;
}
The RecordTypeUnion message is a oneof-style container so a single
record store can hold heterogeneous record types in a shared key range.
RecordMetaDataBuilder reads the .proto, extracts the
primary_key annotation, finds the field.index annotations, and builds
an in-memory RecordMetaData describing every type and index. Source:
com.apple.foundationdb.record.metadata.RecordMetaDataBuilder.
Opening a store
try (FDBDatabase db = FDBDatabaseFactory.instance().getDatabase()) {
db.run(ctx -> {
KeySpacePath path = users.add("env", "prod").add("store", "main");
FDBRecordStore store = FDBRecordStore.newBuilder()
.setContext(ctx)
.setKeySpacePath(path)
.setMetaDataProvider(metaData)
.createOrOpen();
// ... use store ...
return null;
});
}
The createOrOpen path runs a small store header read at offset
<prefix>/0/info to verify schema compatibility. The header records:
- the format version of the on-disk layout,
- the user-supplied metadata version,
- whether the store is in the middle of an online index build.
If any of these donβt match what the caller expects, createOrOpen will
either upgrade the header in place or throw, depending on the
StoreExistenceCheck you passed. This is the schema-evolution
machinery β there is no ALTER TABLE, just an in-place metadata version
bump plus (optionally) a background index build.
saveRecord byte-by-byte
store.saveRecord(User.newBuilder()
.setId(42)
.setName("Alice")
.setEmail("alice@example.com")
.setSignupTs(System.currentTimeMillis())
.build());
This single line expands into roughly the following FDB mutations inside
one transaction. Assume the store prefix has resolved to S (a few
bytes), and the union type tag for User is 1:
| # | Key (logical) | Value | Why |
|---|---|---|---|
| 1 | S / 0 / 0 | store header (read first) | format & schema version check |
| 2 | S / 1 / 1 / 42 β split index 0 | first 100 kB of serialized protobuf | the record itself, primary-key encoded |
| 3 | S / 1 / 1 / 42 / split=1 | next 100 kB if record large | record splitting for >100 kB records |
| 4 | S / 2 / "email" / "alice@..." / 1 / 42 | empty | value index entry on email |
| 5 | S / 3 / 1 / 42 β <versionstamp> | 12-byte versionstamp | record version for __version queries |
| 6 | S / 4 / "User" / 42 | empty | record-type index (if enabled) |
| 7 | tr.AtomicAdd(S / 5 / "recordsByType" / 1, 1) | +1 | aggregate count index |
| 8 | tr.AddWriteConflictRange(S / 4 / "User" / 42 .. /42\x01) | β | optional uniqueness conflict range |
(Sub-key prefixes 0 = store header, 1 = records, 2 = secondary indexes,
3 = record versions, 4 = record-type index, 5 = aggregate indexes β see
FDBRecordStoreKeyspace.)
Two things are worth pausing on:
- Every byte is written in one FDB transaction. Records and all their indexes commit atomically. There is no βindex out of sync with tableβ failure mode.
- Record splitting (rows 2β3) is how the Record Layer handles records larger than FDBβs per-value limit (100 kB). The split key uses a fixed subkey suffix so range-reads can reassemble.
Reading a record
FDBStoredRecord<Message> rec = store.loadRecord(Tuple.from(42L));
User user = User.newBuilder().mergeFrom(rec.getRecord()).build();
loadRecord does a getRange over S / 1 / 1 / 42 / * (to pick up
split parts), concatenates the bytes, decodes the protobuf, and wraps it
in an FDBStoredRecord carrying the primary key and the optional
versionstamp.
Queries and the planner
RecordQuery query = RecordQuery.newBuilder()
.setRecordType("User")
.setFilter(Query.field("email").equalsValue("alice@example.com"))
.build();
RecordCursor<FDBQueriedRecord<Message>> cursor = store.executeQuery(query);
RecordQueryPlanner (in
com.apple.foundationdb.record.query.plan.RecordQueryPlanner) takes the
RecordQuery, inspects the metadataβs indexes, and emits a tree of
RecordQueryPlan nodes. For the example above the plan is one
RecordQueryIndexPlan over the email value index, followed by a
RecordQueryLoadByKeysPlan to fetch each matched record.
Execution is streaming: RecordCursor<T> returns a record plus a
continuation β an opaque byte string that resumes the scan from where
it stopped. Continuations are the answer to βhow do I paginate across
transaction boundariesβ, because FDB transactions are limited to 5 seconds
and 10 MB of writes. A long scan is just many transactions, each fed the
previous oneβs continuation.
Read: RecordCursorIterator, KeyValueCursor, RecordQueryPlanner. The
planner is ~3000 lines; itβs a rules-based planner, not (yet) cost-based,
but the rule set is sophisticated β it understands compound indexes,
covering indexes, sorted unions, and intersection.
7.6 Index Types in Detail
The Record Layer ships eight production index types. Each is implemented
as a subclass of IndexMaintainer, registered through an
IndexMaintainerFactory. The maintainer is called by saveRecord /
deleteRecord with the old and new records and updates its key space
accordingly. Source: com.apple.foundationdb.record.provider.foundationdb.indexes.
| Index type | Key shape | Use case |
|---|---|---|
value | (indexedFields..., pk) β β
| the workhorse: equality + range queries |
rank | (built on top of value) maintains an order-statistic tree | βtop 10 by scoreβ without sorting |
version | (versionstamp, pk) β β
| βeverything that changed since time Tβ |
count | atomic ADD on a single key | row counts |
sum, min, max | atomic on a single key | aggregates |
text | inverted index, one key per (token, pk) | full-text search |
permuted_min/max | maintains the min/max under a grouping | leaderboards |
unique modifier on value | adds write conflict on the index key | enforce uniqueness |
The brilliant trick for count/sum/min/max is that they use FDBβs
atomic mutations (ADD, MAX, MIN) so concurrent writers never
conflict on the aggregate key. Update 10 000 records in parallel; the
count is always exact and no transaction has to retry.
rank is implemented as a skip-list-like layered index, also visible in
the source as RankedSet (com.apple.foundationdb.async.rankset). Itβs
a standalone library you can use without records.
Online index builds
You can add an index to a deployed store without taking it down.
OnlineIndexer (com.apple.foundationdb.record.provider.foundationdb.OnlineIndexer)
scans the records in chunks, indexing each in its own transaction, while
new writes go through the freshly registered maintainer. The index is
marked WRITE_ONLY until the back-fill completes, then promoted to
READABLE. The promotion itself is a single transaction. This is exactly
the trick MySQL ALTER TABLE β¦ ALGORITHM=INPLACE does, only without the
quirks, because the underlying transactional KV makes it straightforward.
7.7 Concurrency, Conflicts, and the 5-Second Rule
Record Layer transactions inherit FDBβs two hard limits:
- 5 seconds of wall-clock time between
getReadVersion()andcommit. - 10 MB of writes per transaction.
So large workloads are decomposed into many small transactions, glued
together by continuations. OnlineIndexer.IndexingPolicy is one example
of this pattern; MultiTransactionalRunner is another.
Two patterns appear over and over in production code:
// Pattern A: chunked write
db.runAsync(ctx -> {
FDBRecordStore store = openStore(ctx);
return AsyncUtil.whileTrue(() -> store
.scanRecords(continuation)
.map(rec -> doSomething(rec))
.onHasNext()
).thenApply(v -> store.getNextContinuation());
});
// Pattern B: read-modify-write with retry
db.run(ctx -> {
FDBRecordStore store = openStore(ctx);
User u = store.loadRecord(Tuple.from(id)).getRecord();
User updated = u.toBuilder().setEmail(newEmail).build();
store.saveRecord(updated);
return null;
}); // db.run() retries on `not_committed` errors automatically
The cost-per-byte of those not_committed retries is precisely
Chapter 5 Β§5.3βs conflict probability formula in
action. If two transactions touch the same primary key and one commits,
the other reads stale data and rolls back. If they touch only different
primary keys, they commit without coordination.
7.8 How This Maps to the Toy Go Implementation
The option-c-record-layer lab in this repo is a deliberately minimal
re-implementation of the same ideas β in Go, in roughly 400 lines, on
top of the official Go FDB bindings. Use it as a Rosetta stone:
| Concept | Apple Record Layer (Java) | This repo (Go) |
|---|---|---|
| Schema | Protobuf .proto + RecordMetaData | Schema{Name, Indexes []string} literal |
| Record | Message (protobuf) | Record = map[string]any |
| Encoding | Tuple layer (TupleUtil) | hand-rolled 0x00 separator, msgpack value |
| Primary key | tuple from annotated fields | string parameter to PutRecord(schema, pk, rec) |
| Index maintenance | IndexMaintainer per type | inline loop over sc.Indexes in store.go |
| Query planner | RecordQueryPlanner | one method per access path (GetRecord, LookupByIndex) |
| Continuation | RecordCursor.getContinuation() | none β scans return whole slice |
| Online index build | OnlineIndexer | not implemented |
| Versionstamps | SetVersionstampedKey atomic op | not implemented |
Look at option-c-record-layer/recordlayer/store.go.
The PutRecord method does exactly steps 2β4 of the byte-by-byte table
in Β§7.5 above:
- Read the old record (to find its old index entries).
- Clear those index entries.
- Write the new record bytes.
- Write the new index entries.
β¦all inside one fdb.Transact() call. Thatβs the whole shape; the
production layer differs in features (rank, text, versioned indexes,
splitting, planner, cursors, online builds) rather than in mechanism.
If you can read the Go file, you have the mental model for the Java one.
Running the lab
cd option-c-record-layer
go run ./demo
# Inserts a few users with a value index on `city`,
# then LookupByIndex("user", "city", "Paris") and prints the results.
Set a breakpoint inside PutRecord and watch the four FDB calls happen
in order. That is the Record Layer in miniature.
7.9 When Should You Use the Record Layer?
Use it when you would otherwise build your own ORM-like layer on FDB: multi-tenant SaaS, document-style storage with secondary indexes, anything where a schema and indexes evolve over time. The Record Layer encapsulates five years of CloudKit production lessons; reinventing it is rarely the best use of your time.
Skip it when:
- You have a single, well-known access pattern and need maximum control over key layout (e.g., the LevelDB-API and SQLite-VFS labs in this repo).
- You donβt want a JVM dependency. (The Record Layer is Java-only today; community ports to other languages do not exist as of this writing.)
- You are doing low-level systems work where the Tuple + Directory + Subspace primitives are enough.
For comparison: Appleβs CloudKit is built on the Record Layer. Snowflakeβs metadata store is built directly on the Java bindings, no Record Layer. Both choices are reasonable; they reflect different trade-offs about schema flexibility vs. layout control.
7.10 Reading Order for the Source
If you decide to learn the upstream source, read in this order. Each step is roughly one focused session.
bindings/java/src/main/com/apple/foundationdb/tuple/TupleUtil.javaβ the tuple encoding. Everything is downstream of this.com.apple.foundationdb.subspace.Subspaceandcom.apple.foundationdb.directory.DirectoryLayerβ namespacing.com.apple.foundationdb.record.metadata.RecordMetaDataandRecordMetaDataBuilderβ how a.protobecomes runtime metadata.com.apple.foundationdb.record.provider.foundationdb.FDBRecordStoreβsaveRecord,loadRecord,deleteRecord. The core. Skim, donβt memorize.com.apple.foundationdb.record.provider.foundationdb.indexes.ValueIndexMaintainerβ the simplest index maintainer; read top to bottom.com.apple.foundationdb.record.query.plan.RecordQueryPlannerβ the planner. Read justplan(RecordQuery)first, then drill into one rule.com.apple.foundationdb.record.provider.foundationdb.OnlineIndexerβ chunked index build with continuations. Beautiful, real example of the 5-second-rule pattern.- The integration tests under
fdb-record-layer-core/src/testβ they are the best documentation.FDBRecordStoreTestBasehas the canonical setup.
After that, jump to the Contributing chapter for the PR workflow, which is essentially the same as FDB itself.
7.11 Interview Questions
Each of these is answered by something earlier in this chapter. If you can answer all five, you understand the Record Layer.
- Why does the tuple layer use 13 different type codes for integers instead of always using 8 bytes?
- How does the Record Layer keep a secondary index consistent with the record it points at, even under concurrent writes?
- What is a continuation, and why does it exist? (Hint: 5-second transaction limit.)
- A user wants to add a new index to a 1 TB record store without
downtime. Walk through what
OnlineIndexerdoes. - You have two record stores in different
KeySpacePaths under the same FDB cluster. Can a single transaction span both? Why or why not? (Hint: yes β they share an FDB cluster β but you must be careful about the 5 s / 10 MB limits and conflict ranges.)
How Real Systems Use FDB
- 4.1 Appleβs FoundationDB Record Layer (iCloud / CloudKit)
- 4.2 FoundationDB Document Layer (MongoDB Wire Protocol)
- 4.3 mvsqlite β SQLite on FDB with Multi-Process MVCC
- 4.4 LevelDBβs goleveldb Storage Interface Pattern
- 4.5 Snowflakeβs Use of FDB
- 4.6 Apple CloudKit β FDB at Hundreds of Millions of Devices Scale
- Interview Questions
One of the most useful ways to understand an architecture is to see it in production at scale. FoundationDB is unusual in that several of the systems built on it are open-source or at least publicly described. Each of the five labs in this repo maps directly to a pattern used in a real, deployed production system.
4.1 Appleβs FoundationDB Record Layer (iCloud / CloudKit)
The fdb-record-layer is a Java library open-sourced by Apple in 2018. It is the storage engine behind Appleβs CloudKit metadata services β the cross-device sync that backs Photos, Contacts, Notes, iMessage, and nearly every other iCloud-connected app.
What it provides:
- Protobuf-typed records (define your schema in
.protofiles) - Declarative index definitions: value indexes, rank indexes, aggregate indexes, text indexes
- A query planner that compiles predicates into FDB range scans
- Schema evolution (add/remove/change fields without downtime)
- Continuations (long queries can be resumed across transactions)
- Change feeds via versionstamped keys
How the key layout maps to our option-c-record-layer:
fdb-record-layer uses a similar two-subspace structure:
[storePrefix] [META_DATA_VERSION] β version metadata
[storePrefix] [RECORD_TYPE_KEY] β schema info
[storePrefix] [RECORD] [pk bytes] β serialized Protobuf record
[storePrefix] [INDEX] [indexName] [indexValue] [pk bytes] β index entry
Our record layer uses a simpler version:
ns + 0x00 + schema + 0x00 + pk β msgpack record
ns + 0x01 + schema + 0x00 + field + 0x00 + value + 0x00 + pk β index entry (empty value)
The production scale: CloudKit syncs data for hundreds of millions of Apple devices. Every Photos upload, every note edit, every contact sync creates record layer transactions. FDB handles this via horizontal partitioning: different usersβ data lives in different FDB shards, so per-user transaction load is low. The global throughput is enormous, but each individual transaction is small.
Key insight β continuations: fdb-record-layerβs most important production feature is the βcontinuationβ β a cursor that can be serialized, stored, and resumed across transaction boundaries. Since FDB limits transaction duration to 5 seconds, long queries must be broken into chunks. A continuation saves the last-seen key so the next transaction can resume exactly where the previous one left off. Our labs donβt implement this, but every production layer needs it for full-table scans, bulk exports, and maintenance jobs.
Schema evolution in production: Adding a new field to a record type is safe (existing records just donβt have it; the field defaults to absent). Removing a field requires first removing all code that writes it, then removing the field from the schema. Renaming a field is the dangerous operation β it looks like βadd new field + remove old fieldβ but requires migrating all existing records. fdb-record-layer provides a dedicated schema migration API for this case.
4.2 FoundationDB Document Layer (MongoDB Wire Protocol)
The Document Layer was an open-source FDB layer that spoke the MongoDB wire protocol. A MongoDB client (driver, shell, or Compass) could point at the Document Layer unchanged, and all data was stored in FDB with ACID guarantees.
What it did:
- Parsed MongoDB wire protocol (BSON over TCP)
- Translated BSON documents into FDB key-value pairs
- Translated MongoDB queries (
{city: "Paris", age: {$gte: 25}}) into FDB range scans and filters - Provided MongoDBβs consistency guarantees (which are weaker than FDBβs β the Document Layer could offer stronger consistency than stock MongoDB by virtue of FDBβs serializable transactions)
Key encoding for a BSON document:
[collectionPrefix] [docId] β BSON document value
[indexPrefix] [fieldName] [fieldValue] [docId] β index entry
This is structurally identical to option-c-record-layer with schema = collectionName.
Why it was deprecated: The Document Layer was maintained by FoundationDBβs internal team at Apple. When Apple open-sourced FDB in 2018 and then in 2019 changed some library licensing, maintaining a full MongoDB-compatible query engine was outside scope. It was deprecated in 2019 but the codebase remains instructive as a reference implementation.
Lesson: A full relational/document query engine can be built atop FDB in ~50,000 lines of C++. The query planner complexity is in compiling predicate trees to efficient index scan strategies. The storage layer is just option-c-record-layer.
4.3 mvsqlite β SQLite on FDB with Multi-Process MVCC
mvsqlite is a Rust project by Heyang Zhou (losfair). It implements a SQLite VFS backed by FDB such that multiple processes can open the same βdatabaseβ concurrently with full MVCC isolation.
How it maps to option-b-sqlite:
Our pagestore stores pages at:
ns + 0x01 + pageNum (big-endian) β 4096-byte page content
mvsqlite stores pages at:
[namespace] [pageNum_big_endian] β page content at that version
What mvsqlite adds beyond our implementation:
-
Page-level MVCC: Instead of storing just the current page, mvsqlite stores multiple historical versions. Old page versions are kept for active readers. This is implemented by appending a version prefix:
[namespace] [pageNum] [snapshotVersion] β page at that versionA reader at snapshot V only reads pages at
version <= V. The GC process cleans up versions older than the oldest active reader. -
Multi-process write serialization: mvsqlite serializes concurrent writers using an FDB transaction on a βwrite lockβ key. The winner gets exclusive write access; losers retry. SQLiteβs own xLock/xUnlock calls are translated to FDB key operations.
-
Write-ahead buffering in FDB: Instead of a local WAL file, mvsqlite buffers pending writes in FDB under a separate subspace. Commit flushes the buffer to the page subspace atomically.
-
Namespace mapping: SQLite filenames map to FDB key prefixes.
ATTACH DATABASE 'db2.mvsqlite' AS db2opens a second namespace in the same FDB cluster.
The production use case: A web application running in a multi-process or multi-instance environment (e.g., multiple Gunicorn workers, multiple Docker containers) can share a single mvsqlite database. Each worker reads from a consistent snapshot without locking other workers. Writes are serialized. This is impossible with standard SQLite (which only allows one writer and assumes a shared filesystem).
4.4 LevelDBβs goleveldb Storage Interface Pattern
The goleveldb storage.Storage interface (used in option-b-leveldb) is a seam designed for exactly this purpose: replacing the local filesystem with a remote storage backend while keeping all of LevelDBβs logic intact.
In practice, option-b-leveldb is analogous to how production systems host embedded databases on shared storage:
Real analog β etcd on block storage: etcd uses bbolt (a B-tree engine) for its data. When running in Kubernetes, etcdβs data directory is a persistent volume (a network-attached block device). The βstorage.Storageβ for etcd is the filesystem API on top of that network block device.
Real analog β TiKVβs RocksDB on FDB: TiKV, the storage layer behind TiDB, uses RocksDB. There are research prototypes that replace RocksDBβs Env (RocksDBβs equivalent of storage.Storage) with a remote storage backend backed by a distributed KV store.
Real analog β FoundationDBβs own Blob Layer: FDBβs official Blob Layer (in the Python layers repository) uses the exact chunking pattern from option-b-leveldb:
[prefix] [blobId] [chunkNum] β up to 10,000 bytes
The chunk size is tunable. Read performance is O(n_chunks) round-trips, optimized by using GetRange to fetch all chunks in one round-trip.
What makes option-b-leveldb interesting architecturally: LevelDB produces 7 types of files (log, sstable, manifest, current, lock, temp, info). Each has different I/O patterns: log files are append-only, sstables are write-once read-many, manifests are written atomically. Our storage.Storage implementation stores all file types as FDB key ranges, but a production implementation would optimize differently per file type (e.g., log files could use FDBβs atomic append via versionstamps rather than read-modify-write chunking).
4.5 Snowflakeβs Use of FDB
Snowflake uses FoundationDB as its metadata store β the catalog that tracks table schemas, partition file locations, transaction history, and access controls. The actual table data is stored in cloud object storage (S3/Azure Blob/GCS), but the metadata that makes queries possible lives in FDB.
Why FDB for metadata?
- Cloud object storage is strongly consistent for object operations but cannot do atomic multi-object updates. Creating a table, writing partition files, and committing a transaction all need to appear atomically or not at all. FDB provides this.
- Metadata is small but highly contended. FDBβs optimistic concurrency and high throughput handle schema-level operations (DDL, partition registration) without bottlenecks.
- FDBβs key ordering makes catalog operations efficient: scan all partitions for table T, scan all columns in schema S, scan all transactions in time range [t1, t2].
The encoding (likely, based on public talks):
[catalog] [tenant] [schemaName] [tableName] [partitionId] β partition metadata
[txnLog] [startVersion] β transaction record
[schema] [tenant] [schemaName] [tableName] β column definitions
This is structurally option-a-sqlite (the SQL catalog pattern) extended to a distributed multi-tenant context.
The lesson: FDB is not used because Snowflake needed a storage engine for query data β S3 handles that. FDB is used because catalog metadata needs ACID guarantees that no other component in a cloud-native stack provides cheaply. If youβre building a distributed system that needs a consistent metadata store, FDB is frequently the correct answer.
4.6 Apple CloudKit β FDB at Hundreds of Millions of Devices Scale
CloudKit is Appleβs cross-device sync infrastructure. It stores photos metadata, notes content hashes, contact sync tokens, and app-defined data for third-party iOS apps. As of 2023, it services hundreds of millions of Apple devices.
The FDB usage in CloudKit:
- User records (contacts, notes, photo metadata) are stored as fdb-record-layer records
- Sync tokens are versionstamped keys that encode the exact FDB version at which a sync point was taken
- Conflict resolution uses FDBβs read-your-writes consistency to detect and merge concurrent edits
Why versionstamps are central to sync:
// Sync token = FDB versionstamp at the time of last sync
// Next sync: "give me all changes since versionstamp V"
// FDB key encoding: versionstamp β big-endian 10-byte key prefix
// GetRange from (V, MAX) returns all records created/modified after V
This is FDBβs versionstamp feature in action: each committed transaction gets a globally monotonic 10-byte version. Records written in that transaction embed the versionstamp in their key, enabling efficient βfetch all changes since Xβ queries with a single range scan.
Horizontal partitioning: Each CloudKit userβs data lives in a dedicated FDB keyspace (their userID is a subspace prefix). No two usersβ transactions conflict because they operate on disjoint keys. This is how FDB achieves per-user isolation without database-per-user overhead.
Interview Questions
Q: Why would you use FDB over Postgres for metadata in a distributed system like Snowflake?
Postgres is a single-node database. To scale writes, you need sharding or replication, which introduces coordination complexity. FDB is horizontally scalable by design: adding machines increases throughput linearly. For metadata that needs global ACID guarantees across hundreds of nodes, a single-node Postgres (or even a replicated Postgres with synchronous replication) cannot match FDBβs throughput. FDB also runs in the same datacenter/cloud region as the application, eliminating cross-region round-trips for metadata operations.
Q: The Document Layer is deprecated β does that mean FDB isnβt good for document storage?
No. The Document Layer was deprecated because maintaining a MongoDB-compatible query engine (including the query planner, aggregation pipeline, and wire protocol) was expensive to maintain. The underlying pattern β document as FDB record, query predicates as range scans over index subspaces β is sound and is exactly what fdb-record-layer implements (with Protobuf instead of BSON). The Document Layerβs death was organizational, not architectural.
Q: How does mvsqlite enable multiple concurrent writers to the same SQLite database?
Standard SQLite uses POSIX file locks (or Windows file locks) to serialize writes. One writer holds a RESERVED lock; others wait. mvsqlite replaces file locks with FDB transactions on a βwrite lockβ key. The writer that wins the FDB transaction proceeds; others retry. Because FDBβs transaction semantics are stronger than POSIX file locks (FDB provides serializable isolation; POSIX locks only prevent concurrent access), mvsqlite actually provides stronger guarantees than standard SQLite in a networked environment.
Q: What is a βcontinuationβ in fdb-record-layer and why does every production FDB layer need one?
FDB limits transaction duration to 5 seconds. Any operation that might take longer β full-table scan, bulk export, index rebuild β must be broken into multiple transactions. A continuation is a serialized cursor: it records which keys have been processed so the next transaction can resume from the right place. Without continuations, a query that touches more records than fit in one 5-second window simply cannot be executed. In our labs, we ignore this constraint β all queries run in one transaction, which fails with a transaction-too-old error if the dataset is large enough. Adding continuation support is the single most important production readiness improvement for any of these layers.
This Repository β Five Implementations
- 5.1 Recommended Reading Order
- 5.2 Layer-by-Layer Architecture Map
- 5.3 Running the Labs
- 5.4 Extending the Labs β Suggested Exercises
This repo contains five working implementations of FDB layers, ordered from conceptually simple to architecturally sophisticated. Each folder is self-contained: it has its own go.mod, source code, and docs/GUIDE.md with a deep dive into how and why that layer is built the way it is.
foundationdb-labs/
βββ option-a-leveldb/ LevelDB API above FDB β docs/GUIDE.md
βββ option-a-sqlite/ SQL engine above FDB β docs/GUIDE.md
βββ option-c-record-layer/ Records + secondary indexes β docs/GUIDE.md
βββ option-b-leveldb/ LevelDB on FDB storage β docs/GUIDE.md
βββ option-b-sqlite/ SQLite VFS substrate β docs/GUIDE.md
5.1 Recommended Reading Order
| Order | Folder | Core concept learned | Difficulty |
|---|---|---|---|
| 1 | option-a-leveldb | Subspaces, MVCC snapshots, write batches, iterators | ββ |
| 2 | option-c-record-layer | Secondary indexes, index consistency, PK encoding | βββ |
| 3 | option-a-sqlite | Table catalog, row encoding, column types | βββ |
| 4 | option-b-sqlite | Page model, byte-range I/O, partial-page RMW | ββββ |
| 5 | option-b-leveldb | File chunks, storage.Storage interface, atomic rename | ββββ |
Start with option-a-leveldb regardless of your background. It introduces all the core patterns β subspaces, range scans, snapshot reads, transaction retry loops β in the simplest possible context.
5.2 Layer-by-Layer Architecture Map
option-a-leveldb (API Layer above FDB)
LevelDB API (Get/Put/Delete/Batch/Iterator/Snapshot)
β
FDB KV store (subspace-encoded keys)
The layer sits above FDB. User code calls db.Put(key, value). The layer encodes the key as ns + 0x00 + key and calls tr.Set(encodedKey, value). All LevelDB semantics (iterators, snapshots, batches) are implemented using FDB primitives.
Why this layer: LevelDBβs API is the lingua franca of embedded KV stores. By implementing it on FDB, existing code that uses LevelDB can be dropped into a distributed, replicated context without changing its API usage.
option-c-record-layer (Record + Index Layer)
Application (PutRecord / GetRecord / LookupByIndex)
β
FDB (two subspaces: records and indexes)
The layer sits above FDB. A record is stored at a fixed key. Every indexed field also has an index key in a separate subspace. Both are written in the same transaction to keep records and indexes consistent.
Why this layer: This is the fundamental pattern for any βdatabaseβ feature. Tables, collections, documents β all of them are βrecords with indexes.β Understanding how to keep a record and its indexes consistent under concurrent writes is the core of database internals.
option-a-sqlite (SQL Engine Layer)
SQL (CREATE TABLE / INSERT / SELECT / UPDATE / DELETE)
β
Table catalog (FDB) + Row store (FDB)
A minimal SQL parser and query engine above FDB. Tables are defined in a catalog (a separate subspace). Rows are encoded as msgpack values keyed by primary key. SELECT * is a range scan over the tableβs key range.
Why this layer: Shows that a full (if minimal) relational database is ~300 lines of Go above FDB. The interesting parts are the catalog encoding and the primary key sort-order problem.
option-b-sqlite (SQLite VFS Substrate)
SQLite (full SQL engine)
β
SQLite VFS API (xRead / xWrite / xSync / xLock)
β
FDB (page-keyed store: pageNum β 4096 bytes)
SQLite provides its own SQL engine, query planner, and transaction management. The VFS is just a storage substrate: given a logical page number, read or write 4096 bytes. FDB stores those pages.
Why this layer: Shows how to extend an existing system by replacing its storage interface rather than reimplementing its logic. The interesting parts are partial-page writes (SQLite calls xWrite with sub-page ranges, requiring read-modify-write) and locking (translating SQLiteβs POSIX lock semantics to FDB key operations).
option-b-leveldb (LevelDB Storage Substrate)
LevelDB (full KV engine)
β
storage.Storage interface (Create / Open / Rename / List)
β
FDB (file metadata keys + chunked file data keys)
LevelDB provides its own full KV engine. The storage layer is just a filesystem abstraction: create/open/read/write/rename/delete files. FDB stores those files as key ranges.
Why this layer: Shows that even complex storage engines like LevelDB (which manage their own B-trees, compaction, and WAL) can be decoupled from the filesystem. The interesting parts are chunk encoding (64KB chunks to stay under FDBβs value limit), atomic rename (LevelDB uses rename to commit new SSTables), and why WAL redundancy is a non-issue (FDBβs own log subsystem replaces the LevelDB WALβs durability purpose).
5.3 Running the Labs
Each lab has a demo/main.go that exercises the layer. To run any lab:
# Start FDB (requires Docker)
docker-compose up -d
# Run a specific demo
cd option-a-leveldb && go run demo/main.go
cd option-c-record-layer && go run demo/main.go
# ... etc.
Prerequisites:
- Docker + Docker Compose (for the FDB cluster)
- Go 1.21+
- The FDB C client library (installed by the bootstrap script:
bash scripts/bootstrap-fdb.sh)
FDB API version: All labs use API version 710 (FDB 7.1.x). This is set at process start:
fdb.MustAPIVersion(710)
5.4 Extending the Labs β Suggested Exercises
Each docs/GUIDE.md has exercises specific to that lab. Here are cross-cutting improvements that apply to multiple labs:
1. Continuations (all labs): Add a ScanWithCursor(cursor []byte, limit int) function that returns at most limit results and a cursor byte string. The cursor encodes the last key seen. Next call resumes from cursor. This makes full-table scans safe under FDBβs 5-second transaction limit.
2. Transactions exposed to the caller (option-a-leveldb, option-a-sqlite): Wrap multiple operations in a user-visible transaction: db.Begin() β Tx, tx.Put(k, v), tx.Get(k), tx.Commit(). Internally, this is just db.Transact(func(tr) { ... }) β all operations share one FDB transaction.
3. Versionstamp-based change feed (option-c-record-layer): When writing a record, also write to a change log subspace: changelogKey = ns + changelogSubspace + versionstamp. A background consumer can do GetRange(lastCheckpoint, MAX) to efficiently read all changes since the last poll. This is how fdb-record-layer implements change feeds.
4. Range index queries (option-c-record-layer): Current index lookup finds exact matches (field = value). Add LookupByRange(schema, field, minValue, maxValue) that does GetRange(indexPrefix(field, minValue), indexPrefix(field, maxValue)). This requires your index value to be sort-preserving encoded (integers via encodeInt64, strings lexicographically).
5. Multi-file SQLite (option-b-sqlite): Support ATTACH DATABASE by mapping SQLite filenames to separate FDB subspaces. Two βattachedβ databases are just two different ns prefixes in FDB β no code duplication needed.
Reading Guide β Where to Go Next
- 6.1 FoundationDB β Going Deeper
- 6.2 Storage Engines β Going Deeper
- 6.3 SQLite VFS β Going Deeper
- 6.4 Distributed Systems Fundamentals
- 6.5 Building Something Real β A Progression
Youβve read through the five chapters of this guide and the lab implementation guides. By now you understand FDBβs architecture from the commit pipeline through every component of the cluster, the layer concept from byte-level encoding through production schema evolution, and how five real systems (Apple CloudKit, Snowflake, mvsqlite, Document Layer, Record Layer) deploy these exact patterns.
This chapter maps out the landscape of resources for going deeper in each direction.
6.1 FoundationDB β Going Deeper
Essential Reading
FDB Documentation β The official reference. The βDeveloper Guideβ section covers API semantics in depth, including the precise semantics of read-your-writes consistency, exactly which operations conflict, and the full list of transaction options.
FDB White Paper β SIGMOD 2021 β βFoundationDB: A Distributed Unbundled Transactional Key Value Store.β The definitive technical reference for FDBβs internal architecture. Covers the simulation framework, commit pipeline, log system, and storage layer in academic depth. If youβve read this guide, you have the vocabulary to understand every section of this paper.
FDB Forum β Design discussions, Q&A, and announcements. Some of the most insightful posts are from FDBβs core team explaining design decisions.
Source Code
apple/foundationdb β The full FDB C++ source. Key files:
fdbserver/MasterProxyServer.actor.cppβ Commit Proxy implementation (the commit pipeline)fdbserver/Resolver.actor.cppβ Resolver (conflict detection)fdbserver/TLogServer.actor.cppβ Transaction Log (write-ahead log)fdbserver/StorageServer.actor.cppβ Storage Server (reads, MVCC)fdbclient/ReadYourWrites.actor.cppβ Client-side read-your-writes cachefdbserver/workloads/β Simulation workloads (randomized fault testing)
FoundationDB/fdb-record-layer β Java. The most complete example of a production FDB layer. Study FDBRecordContext (transaction management), RecordQueryPlanner (query compilation to range scans), and OnlineIndexer (safe online index builds). Reading this source after this guide will be directly comprehensible.
6.2 Storage Engines β Going Deeper
Books
Designing Data-Intensive Applications by Martin Kleppmann β The best single-volume overview of storage trade-offs, replication, consistency, and distributed transactions. Chapters 3 (storage engines), 7 (transactions), and 9 (consistency) are directly relevant. If you havenβt read this book, stop here and read it. It contextualizes everything in this guide.
Database Internals by Alex Petrov β Deep dive into B-trees (B+ variants, page splits, page merges), LSM trees (all the internals: bloom filters, compaction algorithms, manifest management), and distributed consensus (Paxos, Raft, Multi-Paxos). The LSM chapter is the best public explanation of RocksDB/LevelDB internals.
Papers
The Log-Structured Merge Tree (1996) β The original LSM paper by OβNeil et al. Defines the C0/C1/C2 component model that LevelDB simplifies into level-0/level-1/β¦
Bigtable: A Distributed Storage System for Structured Data (2006) β The architecture paper for Google Bigtable. Introduced the tablet-server model, SSTable format, and hierarchical metadata server. Direct ancestor of HBase, Cassandraβs SSTables, and FDBβs storage layer design.
Spanner: Googleβs Globally-Distributed Database (2012) β How Google built a globally distributed ACID database using TrueTime for external consistency. The βPaxos groupsβ concept is closely related to FDBβs shard-level durability. Essential reading for understanding distributed transactions.
Source Code
google/leveldb β The C++ LevelDB. Read: db/version_set.cc (compaction and manifest management), db/log_reader.cc + db/log_writer.cc (WAL format), table/block.cc + table/format.cc (SSTable on-disk format), util/arena.cc (memtable arena allocator).
syndtr/goleveldb β The Go LevelDB used in option-b-leveldb. The storage/ package defines the storage.Storage interface that our layer implements.
sqlite.org/sqlite β The SQLite source. Study: btree.c (B-tree implementation), vfs.c (VFS abstraction), pager.c (page cache and WAL). The SQLite source is famously well-commented.
facebook/rocksdb β RocksDB is LevelDBβs production successor. Much more complex (tiered compaction, bloom filters, column families, transactions) but the fundamental data model is identical.
6.3 SQLite VFS β Going Deeper
SQLite VFS Documentation β The full VFS API with method semantics. Essential for understanding what each of the xRead, xWrite, xSync, xLock, xUnlock methods must guarantee.
SQLite File Format β The page-by-page layout of a SQLite database file. After reading the option-b-sqlite guide, this document will make complete sense. The page header format, freelist, overflow pages, and B-tree cell formats are all documented here.
losfair/mvsqlite β The production FDB-backed SQLite VFS. The Rust code is clean and readable. The mvfs/ directory contains the VFS implementation; mvsqlite/ contains the page store. Compare with option-b-sqlite/pagestore/pagestore.go line by line.
SQLite WAL Mode β The WAL (Write-Ahead Logging) mode documentation. Explains how WAL provides concurrent reads and writes. Our pagestore uses rollback journal mode semantics (xSync is a no-op because FDB is durable), but understanding WAL mode helps you see what weβre not implementing.
6.4 Distributed Systems Fundamentals
The Part-Time Parliament (Paxos) β Lamportβs original Paxos paper. Dense, but the consensus problem it solves (how do N machines agree on one value when any machine can fail?) is the foundation of every distributed database.
In Search of an Understandable Consensus Algorithm (Raft) β The Raft paper. More accessible than Paxos. FDB uses a Paxos variant, not Raft, but the problems solved are identical. After this, read the Raft visualization.
Linearizability: A Correctness Condition for Concurrent Objects β The formal definition of linearizability (which FDB provides for reads and writes) and its relationship to sequential consistency and serializability.
A Critique of ANSI SQL Isolation Levels β The Berenson et al. paper that formalized isolation anomalies (dirty reads, non-repeatable reads, phantoms, lost updates, write skew) and showed that ANSI SQLβs definitions were imprecise. Essential vocabulary for discussing βserializableβ vs βsnapshot isolationβ vs βrepeatable read.β
6.5 Building Something Real β A Progression
If you want to go from βI understand these labsβ to βI built something production-worthy,β here is a concrete progression:
Step 1: Add continuations to option-a-leveldb.
Add IterateWithCursor(cursor []byte, limit int) ([]KV, nextCursor []byte). This is the single most important production feature missing from all the labs. Every real FDB application needs cursor-based pagination for range queries.
Step 2: Add range index queries to option-c-record-layer.
Implement LookupByRange(schema, field string, minVal, maxVal interface{}) ([]Record, error). This requires: (a) sort-preserving encoding for all index value types, and (b) a range scan on the index subspace instead of a point lookup.
Step 3: Implement a simple query planner.
For WHERE city='Paris' AND age >= 25, decide: which index scan is more selective? Scan city=βParisβ and filter age, or scan age >= 25 and filter city? Look at how fdb-record-layerβs RecordQueryPlanner.java makes this decision.
Step 4: Add online index building.
Given a table with 1 million records, add a new index without downtime. The approach: (a) mark index as βbuildingβ, (b) background job scans records in cursor-paginated chunks and writes index entries, (c) any concurrent PutRecord/DeleteRecord writes to both old and new index state, (d) when background job finishes, mark index βreadyβ. This is how fdb-record-layerβs OnlineIndexer works.
Step 5: Read the fdb-record-layer source.
After steps 1-4, open fdb-record-layerβs FDBRecordStore.java, RecordQueryPlanner.java, and OnlineIndexer.java. You will understand every design decision immediately. The gap between βlabs exerciseβ and βproduction library used at Apple scaleβ is these four features: continuations, sort-preserving range indexes, query planning, and online index builds.
Contributing to FoundationDB
- 10.1 Building FoundationDB from Source
- 10.2 Finding a First Bug to Fix
- 10.3 The Contribution Workflow
- 10.4 Where to Hang Out
- 10.5 First-PR Idea Catalog
- 10.6 Reading the Source β A Recommended Sequence
- 10.7 Beyond the Core: Layers and Tooling
- 10.8 The Long Game
βThe best way to learn a database is to fix a bug in it.β
This chapter is the runway from βI read the bookβ to βI have a merged PR on
apple/foundationdb.β It is opinionated and concrete. The official
CONTRIBUTING.md
is the canonical source; this chapter is the operational walkthrough.
10.1 Building FoundationDB from Source
The build is the gate that filters most would-be contributors. Block off an afternoon the first time. Once it works, it works forever.
Prerequisites
You need:
- A Linux machine (or a Linux VM / container on macOS). The macOS build is partially supported but most maintainers test on Linux. Use Linux.
- 16 GB RAM minimum, 32 GB recommended. The build is large.
- 40 GB free disk space.
- Docker (the official build uses a containerized toolchain).
The official path: build_run.sh
git clone https://github.com/apple/foundationdb.git
cd foundationdb
# Builds inside the official Docker toolchain image; output in build/
./contrib/Joshua/scripts/local_correctness.sh # one-shot, prints a sim test result
For interactive development:
docker run -it --rm \
-v $PWD:/foundationdb \
-w /foundationdb \
foundationdb/build:centos7-latest \
bash
# Inside the container:
mkdir -p build && cd build
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug ..
ninja -j$(nproc)
First build takes ~20 min on a fast laptop; incremental builds are usually < 1 min.
What gets built
| Binary | Purpose |
|---|---|
bin/fdbserver | The server daemon β every cluster role runs this binary |
bin/fdbcli | Interactive cluster admin client |
bin/fdbbackup, fdbrestore | Backup tooling |
lib/libfdb_c.so | The client library β what Go/Java/Python bindings load |
bin/joshua_agent | Distributed simulation test runner |
bin/mako | Load generator |
Sanity check: run a simulation
./bin/fdbserver -r simulation -f ../tests/fast/CycleTest.toml
A successful run prints SimulatedFDBD: simulation finished successfully
after 30 s to a few minutes. If you get an ASSERT failure, write down the
seed β you may have already found a regression.
10.2 Finding a First Bug to Fix
Three reliable sources of approachable issues:
Source 1: The issue tracker, labeled appropriately
Documentation issues are the most under-served. The official docs at
apple.github.io/foundationdb are
maintained from documentation/sphinx/source/. A clear, well-tested
documentation PR is almost always merged quickly and is excellent practice
for the contribution workflow.
Source 2: Run the simulator until something breaks
The simulator is designed to find bugs that humans missed. Run it for hours:
for seed in $(seq 1 200); do
./bin/fdbserver -r simulation \
-f tests/fast/RandomReadWriteTest.toml \
--random-seed $seed \
> /tmp/sim-$seed.log 2>&1 || echo "FAIL seed=$seed"
done
If you find a reproducible failure on main that doesnβt appear on the most
recent stable release, youβve found a regression. File it with the seed; if
you can also localize it via git bisect, youβre 80% to a merged PR.
Source 3: Performance investigation
The performance hotspots change with every release. Use perf against an
fdbserver running a mako workload:
mako --mode build --rows 10000000 ...
perf record -g -p $(pgrep -f 'fdbserver.*storage') -- sleep 30
perf report --stdio | head -100
Anything taking > 5% of CPU in a place that doesnβt make architectural sense
is a candidate for optimization. The
storageserver.actor.cpp
hot loop is a perennial source of incremental wins.
10.3 The Contribution Workflow
- Open an issue first for anything non-trivial. The maintainers will tell you if (a) someone is already on it, (b) the approach is wrong, or (c) theyβd prefer a different scope. Saves rework.
- Sign the CLA. The bot will prompt on your first PR. Individual CLA is fine; corporate CLA needed if your employer holds your IP.
- Branch from
main. Topic branches likefix-storage-leak-12345. - One logical change per PR. Refactors stay separate from bug fixes. Maintainers reject mixed PRs quickly.
- Add a simulation test for behavior changes. This is non-negotiable. The reviewer will ask. If you canβt write one, ask for help in the issue or on the forum β but the test must land in the same PR.
- Run the relevant simulation tests locally before pushing:
ctest -L fast -j$(nproc) # ~15 min, runs the "fast" test suite - CI will run Joshua (the distributed sim runner) against your PR. Failures with seeds are surfaced in the PR comment. Reproduce locally with the printed seed.
Style and conventions
- C++17, formatted by
clang-formatusing the repoβs.clang-format. Run./contrib/clang-format-hook.shor set up the pre-commit hook. - No
std::unordered_map, no rawgettimeofday, nostd::thread. The determinism rules from Chapter 4 are enforced in review. - Knobs go in
Knobs.hwith a sensible default; donβt add config flags unless absolutely necessary. - Public Flow APIs (touched by the client library) need binding tests in
bindings/c/test/and equivalents for at least Python and Java.
10.4 Where to Hang Out
The FDB community is small but active.
| Venue | Use for |
|---|---|
| forums.foundationdb.org | Design discussion, βhow does X workβ questions |
#foundationdb on Apache Slack | Quick chat (request invite via forum) |
| GitHub Discussions | Feature proposals |
| FoundationDB Summit (annual) | Talks from Apple, Snowflake, Tigris, Stark+Wayne |
Read at least one forum thread per week for a month before posting; youβll absorb the teamβs writing style and avoid asking already-answered questions.
10.5 First-PR Idea Catalog
If you want a concrete starting point, here are the kinds of contributions that historically have been merged from new contributors:
- Documentation: clarify a confusing section. Pick a doc page that confused you while reading this book. Improve it. Submit it.
- Add an example to the binding tests. Each language binding has a
test/subdirectory; many corner cases are under-tested. Add a test fortr.GetEstimatedRangeSizefrom your favorite binding. - Improve an error message. Run a test that hits an FDB error code,
read what FDB tells you, and rewrite a confusing message in
fdbclient/FDBTypes.hto be actionable. - Add a knob. Find a hardcoded constant in
fdbserver/that looks like it should be tunable (search for magic numbers in.actor.cppfiles). Move it toKnobs.h, give it a default, and add a release note. - Write a workload. New simulation workloads live in
fdbserver/workloads/. Pick a real-world pattern your job uses (e.g., a producer-consumer queue) and write a workload that exercises it under chaos. The simulator will probably find a bug for you. - Port a binding. There are unofficial bindings for Rust, Zig, Elixir. If your favorite language is missing, the C client API is small (~50 functions); a from-scratch binding is a great multi-month project.
- Improve Redwoodβs cold-start time. A known issue: opening a large Redwood store on cold startup can take minutes because the page metadata index is walked sequentially. Various heuristics (parallel walk, mmap, prefetch) have been proposed. Pick one, prototype it, benchmark it.
10.6 Reading the Source β A Recommended Sequence
Even before contributing, reading the source is the best self-education in distributed systems available outside graduate school. A recommended sequence:
flow/β internalize the actor model.flow.h,flow.cppβ Future, Promise, SAV.Net2.actor.cppβ the event loop.genericactors.actor.hβ Flowβs βstandard library.β
fdbrpc/β RPC framework on top of Flow.FlowTransport.actor.cppβ sending/receiving requests.sim2.actor.cppβ the simulation backend.
fdbserver/β read in this order:MasterServer.actor.cpp(orSequencerServer.actor.cppin newer versions) β version assignment, the heart of MVCC.CommitProxyServer.actor.cppβ the commit pipeline.Resolver.actor.cppβ conflict resolution.TLogServer.actor.cppβ the distributed WAL.storageserver.actor.cppβ the read/apply loop.DataDistribution.actor.cppβ shard movement.VersionedBTree.actor.cppβ Redwood, thenDeltaTree.h.ClusterController.actor.cppβ the role manager (last; itβs complex).
fdbclient/β how clients see the cluster.NativeAPI.actor.cppβ transaction lifecycle.MultiVersionTransaction.actor.cppβ the client-version compatibility dance.
bindings/c/β the C API your CGO Go binding actually calls.
If you give yourself one file per evening, youβll read the whole essential codebase in a month β and you will be one of perhaps a few hundred people in the world who have.
10.7 Beyond the Core: Layers and Tooling
Contributing to the core engine is one path. Equally valuable:
- fdb-record-layer (FoundationDB/fdb-record-layer) β the Java layer used inside CloudKit. Less code than the core, very active.
- fdb-document-layer (FoundationDB/fdb-document-layer) β a MongoDB-compatible document store on FDB. Looking for maintainers.
- fdb-kubernetes-operator (FoundationDB/fdb-kubernetes-operator) β Go, very approachable, fast PR turnaround.
- Tigris (tigrisdata-community/tigris) β open-source serverless document DB on FDB.
- mvsqlite (losfair/mvsqlite) β runs SQLite on FDB, the inspiration for this repoβs option-b-sqlite lab.
These projects are smaller, faster-moving, and often very grateful for help. The patterns they use are the exact patterns this book covers.
10.8 The Long Game
The FoundationDB team is small. The maintainers know everyone who has landed more than a few patches. After 5β10 merged PRs you will be on a first-name basis with people whose names you originally saw in citations. After 50, youβll be invited to design discussions and given commit privileges.
The path is real. The bar is high but not arcane: write correct C++, write deterministic Flow, run the simulator, follow the conventions, be patient in review. Every senior contributor started exactly where you are now.
βSo long, and thanks for all the fish.β
Now go build something on FoundationDB β and then go fix something in it.
Option A β LevelDB API on top of FoundationDB
Pattern: βAPI layer above LevelDBβ β we keep the familiar LevelDB surface (
Put/Get/Delete/Batch/Iterator/Snapshot) but the bytes never touch LevelDB. They live in FoundationDB instead.
Why this is interesting
LevelDB is a local embedded KV. FoundationDB is a distributed transactional
KV. Both speak (key, value) pairs over ordered byte keys, so the API shapes
are nearly identical β but the guarantees underneath are wildly different:
| Concept | LevelDB | This layer (over FDB) |
|---|---|---|
| Put | Append to MemTable + WAL on local disk | Set inside tr.Transact{...} |
| Get | MemTable β immutable tables β SSTs | tr.Get (consistent across the cluster) |
| Batch | Single WAL record | One FDB transaction (cross-key atomic) |
| Range | LevelDB SST merging iterator | tr.GetRange over a Subspace |
| Snapshot | Pins on-disk sequence number | SetReadVersion(capturedVersion) (MVCC) |
The mapping is almost mechanical, and thatβs the point: it makes FDBβs primitives concrete.
Files
layer/
encoding.go Subspace: ns + 0x00 + userKey β fdb.Key, plus range helpers
db.go Open / Close / Get / Put / Delete
batch.go Batch + DB.Write (atomic multi-op)
iterator.go Forward+backward cursor over GetRange results
snapshot.go MVCC snapshot via captured read version
demo/main.go End-to-end: CRUD β batch β range β snapshot vs. live reads
Key-space layout
<namespace> 0x00 <userKey> -> <value>
Subspace (see layer/encoding.go) keeps multiple logical
databases isolated inside one FDB cluster. The 0x00 separator + the fact
that the user-key portion is appended as opaque bytes gives us the same
lexicographic ordering LevelDB users expect.
Mapping each LevelDB op to an FDB call
Put(k,v)βdb.Transact(func(tr){ tr.Set(ns.Pack(k), v) }).Transacthandles automatic retry on conflict.Get(k)βdb.ReadTransact(func(rt){ rt.Get(ns.Pack(k)).Get() }). An empty FDB result (nil) becomes ourErrNotFound.Delete(k)βtr.Clear(ns.Pack(k))(no-op if absent).Batch.Writeβ a singleTransactcontaining manySet/Clearops. Either all of them land, or none do.Iteratorβ materialized list fromtr.GetRange(subspace.Range()). Real production code would stream viaRangeIterator, but materializing keeps the iterator usable outside a live transaction (the LevelDB shape).Snapshotβ capture the read version withtr.GetReadVersion(), then on subsequent reads calltr.SetReadVersion(captured)so FDB serves the data as it was at that point in time.
Running
- Start the shared FDB cluster (from the repo root):
docker compose up -d ./scripts/bootstrap-fdb.sh - Install the FDB client library on the host (required by the Go bindings,
which are CGO-linked to
libfdb_c):brew install foundationdb # macOS - Run the demo:
cd option-a-leveldb
go run ./demo
The demo defaults to `../fdb.cluster` (or `FDB_CLUSTER_FILE` if set).
Expected output (abridged):
Get apple -> red
batch applied (cherry+date inserted, banana deleted)
range scan [a, z):
apple = red
cherry = red
date = brown
snapshot read version = 1234567
live Get apple -> green
live Get cherry -> layer: not found
snap Get apple -> red (was βredβ at snapshot)
snap Get cherry -> red (err=
That last block is the punchline: the snapshot sees the world before our
post-snapshot writes, because FDB just gives us older versions on demand.
## What this layer intentionally omits
- **Compaction / SST files** β there are none. FDB handles storage internally.
- **Bloom filters** β not needed; FDB indexes by key range natively.
- **Write batches with sequence numbers** β FDB's transaction commit version
plays that role automatically (`tr.GetCommittedVersion()` after commit).
See `../option-b-leveldb` for the inverse experiment: keeping *real* LevelDB
code paths (memtable + SSTs) but storing the SST bytes in FDB.
Hitchhikerβs Guide β Option A: LevelDB API over FoundationDB
- Table of Contents
- 1. What LevelDB Is
- 2. The Five Concepts
- 3. Subspaces β The Key Encoding
- 4. Write Batches β Atomicity Made Explicit
- 5. Iterators β Range Scans Without Cursors
- 6. Snapshots β MVCC Exposed to the Caller
- 7. The Demo, Step by Step
- 8. What the Real LevelDB Does That We Donβt
- 9. Real-World Analogue: goleveldb, RocksDB, PebbleDB
- 10. Exercises β Build on This
- 11. Source Code Deep Dive β Every Line Explained
- 12. Production Considerations
- 13. Interview Questions β LevelDB, MVCC, and FDB
The question this answers: βI have code that uses LevelDB. Can I swap in FoundationDB underneath with zero changes to my application?β
The deeper question: βWhat is LevelDBβs API contract, and how exactly does each piece of it map onto FDB primitives?β
Table of Contents
- What LevelDB Is (and Isnβt)
- The Five Concepts: Get, Put, Delete, Batch, Iterator, Snapshot
- Subspaces β The Key Encoding
- Write Batches β Atomicity Made Explicit
- Iterators β Range Scans Without Cursors
- Snapshots β MVCC Exposed to the Caller
- How the Demo Works Step by Step
- What the Real LevelDB Does That We Donβt
- Real-World Analogue: goleveldb, RocksDB, PebbleDB
- Exercises β Build on This
1. What LevelDB Is
LevelDB (released by Google in 2011) is an embedded, ordered, keyβvalue store implemented as a Log-Structured Merge Tree (LSM tree). βEmbeddedβ means it runs in the same process as your application β no server, no network. βOrderedβ means the same thing as in FDB: keys are sorted lexicographically, and you can range-scan them efficiently.
LevelDBβs API surface is deliberately tiny:
db.Get(key)
db.Put(key, value)
db.Delete(key)
batch := new(leveldb.Batch)
batch.Put / batch.Delete
db.Write(batch)
iter := db.NewIterator(...)
snap := db.GetSnapshot()
This simplicity is why LevelDB became the embedded storage engine of choice for Chrome (IndexedDB), Bitcoin Core, and countless other applications.
Where LevelDB lives in the storage stack:
Application code
β
LevelDB API (Get/Put/Delete/Batch/Iterator)
β
LSM tree (MemTable + SSTables on disk)
β
Filesystem / OS
Where our layer lives:
Application code
β
[our layer] β same LevelDB-shaped API (Get/Put/Delete/Batch/Iterator)
β
FDB transactions
β
FDB cluster (distributed, replicated)
The application sees the same interface. The durability substrate changes completely.
2. The Five Concepts
Get β One Read, One Transaction
In LevelDB, Get opens a brief βread lockβ (via a snapshot) and reads
one key. There is no explicit transaction; LevelDB handles it internally.
In our layer:
func (d *DB) Get(key []byte) ([]byte, error) {
v, err := d.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.Get(d.ns.Pack(key)).Get()
})
...
}
ReadTransact opens a read-only FDB transaction (no conflict tracking on
writes, cheaper than a full Transact). The read version is chosen by FDB
to be a recent committed version β typically a few milliseconds behind
real-time. This gives you a consistent view even if concurrent writers are
active.
rt.Get(k) does not block on return. It returns a FutureByteSlice.
Calling .Get() on the future is what blocks (sends the request to the FDB
storage server and waits for the response). This two-phase call style is how
FDB supports pipelining: you can call rt.Get(k1), rt.Get(k2),
rt.Get(k3) in sequence, then .Get() all three β FDB sends all three
requests before blocking on any response. This is critical for
LookupByIndex in option-c, as weβll see.
Put β One Write, One Transaction
func (d *DB) Put(key, value []byte) error {
_, err := d.fdb.Transact(func(tr fdb.Transaction) (interface{}, error) {
tr.Set(d.ns.Pack(key), value)
return nil, nil
})
return err
}
Transact opens a read-write transaction. tr.Set adds the (key, value)
pair to the transactionβs local write buffer. Nothing is sent to the cluster
until the function returns without error, at which point FDB commits. If
there was a conflict (another writer touched this key since our read version),
FDB calls our function again with a fresh transaction automatically.
Delete β Same as Put, but Clear
tr.Clear(key) adds a tombstone to the write buffer. In FDBβs model, a
cleared key is identical to a key that was never written β there is no βnullβ
value. This is important: Get on a cleared key returns nil (which our
layer translates to ErrNotFound), not a special tombstone value.
When Each One is Right
| Operation | Use when | FDB primitive |
|---|---|---|
| Get | Reading a single key | ReadTransact |
| Put | Writing a single key | Transact + Set |
| Delete | Removing a single key | Transact + Clear |
| Batch | Writing multiple keys atomically (below) | Transact (one) |
3. Subspaces β The Key Encoding
Every key that hits FDB is first passed through the Subspace.Pack method:
// encoding.go
type Subspace struct{ prefix []byte }
func (s Subspace) Pack(userKey []byte) fdb.Key {
out := make([]byte, 0, len(s.prefix)+1+len(userKey))
out = append(out, s.prefix...)
out = append(out, 0x00) // separator byte
out = append(out, userKey...)
return fdb.Key(out)
}
If your namespace is "demo" and your key is "apple", the actual FDB key
is the byte string "demo\x00apple".
Why the separator byte?
Without a separator, two namespaces "foo" and "foobar" would collide:
the key "foobar\x00somekey" would appear inside both fooβs range and
foobarβs range. The separator \x00 prevents this because "foo\x00" is
not a prefix of "foobar\x00".
Why 0x00 specifically?
Because 0x00 is the smallest possible byte value. When we compute the range
end for a subspace scan, we copy the begin key and change the last byte from
0x00 to 0x01:
func (s Subspace) Range() fdb.KeyRange {
begin := append([]byte{}, s.prefix...)
begin = append(begin, 0x00)
end := append([]byte{}, s.prefix...)
end = append(end, 0x01)
return fdb.KeyRange{Begin: fdb.Key(begin), End: fdb.Key(end)}
}
The range ["demo\x00", "demo\x01") contains exactly and only the keys
packed by this subspace. This is a simple, efficient way to express βall keys
in this namespaceβ without needing a sentinel end key.
The tuple layer alternative:
Official FDB client libraries encode subspace ranges using the Tuple encoding, which handles nested subspaces, escaping, and typed values. Our hand-rolled encoding is simpler but less general β for production use, adopt the Tuple layer.
4. Write Batches β Atomicity Made Explicit
LevelDBβs WriteBatch is the mechanism for writing multiple keys
atomically. Without a batch, each Put is a separate transaction β if your
process crashes between two Puts, the second one is missing.
b := layer.NewBatch()
b.Put([]byte("user:1:name"), []byte("Alice"))
b.Put([]byte("user:1:email"), []byte("alice@example.com"))
b.Put([]byte("user:1:score"), []byte("100"))
db.Write(b)
After Write, either all three keys exist or none do.
Our implementation:
// batch.go
func (d *DB) Write(b *Batch) error {
_, err := d.fdb.Transact(func(tr fdb.Transaction) (interface{}, error) {
for _, op := range b.ops {
if op.clear {
tr.Clear(d.ns.Pack(op.key))
} else {
tr.Set(d.ns.Pack(op.key), op.value)
}
}
return nil, nil
})
return err
}
One Transact call. All ops go into the transaction buffer together. FDB
commits them atomically. This is exactly what LevelDBβs WriteBatch does
internally β it writes all ops to the Write-Ahead Log in one fsync, then
applies them to the MemTable.
FDBβs size limits:
FDB transactions are capped at approximately 10 MB of reads + writes. For most use cases this is not a limit, but if youβre writing millions of keys youβll need to split into multiple transactions. See option-b-leveldb for the chunking pattern.
5. Iterators β Range Scans Without Cursors
LevelDBβs iterator is a cursor over the sorted key space. It supports bidirectional movement and seeking to arbitrary positions.
The streaming vs. materializing decision:
A βstreamingβ iterator would keep a live FDB transaction open and use
fdb.RangeIterator to fetch keys page by page as you call Next(). This
is efficient for large ranges but ties the iteratorβs lifetime to an open
transaction.
FDB transactions have a ~5 second timeout. LevelDB iterators are frequently held open much longer (e.g., while a background compaction reads a full SSTable). Forcing a 5-second limit would break drop-in compatibility.
Our solution: materialize the entire range into a slice upfront:
func newIteratorAt(fdbDB fdb.Database, ns Subspace, start, end []byte, readVersion int64) *Iterator {
v, _ := fdbDB.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(ns.RangeWithin(start, end), fdb.RangeOptions{}).GetSliceWithError()
})
it.kvs = v.([]fdb.KeyValue)
...
}
The entire GetSliceWithError call happens inside one transaction. The
transaction closes. The iterator holds the materialized slice β it can
outlive the transaction indefinitely.
Trade-off: We read all matching keys eagerly, even if the caller only needs the first few. For small ranges (as in most LevelDB use cases) this is fine. For ranges spanning millions of keys, a streaming approach would be necessary.
Navigation:
it.First() // idx = 0
it.Next() // idx++
it.Prev() // idx--
it.Last() // idx = len(kvs)-1
it.Seek(target) // binary (or linear) search for key >= target
it.Key() // kvs[idx].Key unpacked from subspace
it.Value() // kvs[idx].Value
it.Valid() // 0 <= idx < len(kvs)
The Seek in our implementation is a linear scan for pedagogical clarity.
A production implementation would use sort.Search (binary search) since
the slice is sorted.
6. Snapshots β MVCC Exposed to the Caller
This is where FDBβs MVCC machinery becomes directly visible.
A LevelDB snapshot captures the database state at a point in time. Reads through the snapshot always see that exact state, regardless of subsequent writes.
snap := db.NewSnapshot()
// ... later, after writes have occurred ...
v1, _ := db.Get(key) // sees current state
v2, _ := snap.Get(key) // sees state at snapshot time
// v1 != v2 if the key was mutated after the snapshot
How we implement this:
// snapshot.go
type Snapshot struct {
db fdb.Database
readVersion int64
}
func (d *DB) NewSnapshot() (*Snapshot, error) {
rv, err := d.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetReadVersion().Get()
})
...
return &Snapshot{db: d.fdb, readVersion: rv.(int64)}, nil
}
GetReadVersion() returns the logical timestamp FDB assigned to our
transaction. This is a monotonically increasing integer β FDBβs βversion
clockβ. We store it.
Later, when the snapshot is asked for a key:
func (s *Snapshot) Get(key []byte) ([]byte, error) {
v, err := s.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
tr.SetReadVersion(s.readVersion) // β the key line
return tr.Get(s.ns.Pack(key)).Get()
})
...
}
SetReadVersion pins the transaction to read from the exact version we
captured. FDBβs MVCC machinery ensures that the storage servers still have
the old version in their version history β provided it hasnβt been garbage
collected (the ~5 second window).
Why SetReadVersion requires a writable Transaction:
This is a quirk of the FDB Go API: SetReadVersion is only available on
fdb.Transaction (read-write), not on fdb.ReadTransaction (read-only).
So we open a writable transaction but never write anything β effectively using
it as a βpinnable read transactionβ. The transaction is automatically
abandoned when the closure returns.
What this maps to in real databases:
- PostgreSQL:
BEGIN; SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;creates a snapshot valid for the transactionβs duration - MySQL InnoDB:
START TRANSACTION WITH CONSISTENT SNAPSHOT - Spanner:
BeginTransaction(mode: READ_ONLY, timestamp: exact_staleness: Duration) - CockroachDB:
AS OF SYSTEM TIME <timestamp>clause on SELECT
All of these are, at their core, βread from this specific version of the MVCC chain.β
7. The Demo, Step by Step
The demo in demo/main.go exercises every feature and prints the results.
Here is the internal FDB call sequence for the MVCC section:
1. db.Put("color", "red")
β Transact: Set("demo\x00color", "red"), commit version v1
2. snap = db.NewSnapshot()
β ReadTransact: GetReadVersion() β returns v1 (or very close)
β snap.readVersion = v1
3. db.Put("color", "blue")
β Transact: Set("demo\x00color", "blue"), commit version v2
4. snap.Get("color")
β Transact: SetReadVersion(v1), Get("demo\x00color")
β FDB returns "red" (value at v1)
5. db.Get("color")
β ReadTransact: Get("demo\x00color")
β FDB returns "blue" (latest committed value)
This demonstrates that the snapshot truly pins to v1, seeing the pre-mutation value.
8. What the Real LevelDB Does That We Donβt
LevelDB has significant machinery we skip:
LSM Tree internals:
- MemTable (skip list in memory) + WAL (write-ahead log on disk)
- SSTable files (sorted, immutable, bloom-filter indexed)
- Compaction (background merging of SSTables to reclaim space and bound read amplification)
- Bloom filters (avoid disk reads for non-existent keys)
None of this applies to our layer because FDB handles all of it. FDBβs
storage servers use their own B-tree-like storage (a custom tree called the
βFDB B-treeβ that supports MVCC) and their own write-ahead logs. We simply
call tr.Set and FDBβs internals handle the rest.
What we deliberately emulate:
- The API surface (same method names and semantics as goleveldb)
- Namespace isolation (subspace = virtual database)
- Atomic batches
- Forward/backward iteration
- MVCC snapshots
What we donβt emulate:
- Compaction (irrelevant β FDB handles it)
- Bloom filters (FDB has its own read optimization)
- File-level operations (no such thing in FDB)
- Checksums (FDB provides end-to-end data integrity)
9. Real-World Analogue: goleveldb, RocksDB, PebbleDB
goleveldb (github.com/syndtr/goleveldb) is the Go port of LevelDB that
option-b-leveldb uses as a consumer. Its storage.Storage interface is the
pluggable storage backend. Weβll see this in option-bβs guide.
RocksDB (Facebook/Meta, 2013) is the evolution of LevelDB: more configurable, multi-threaded compaction, column families, transactional API. It is the storage engine inside MySQL 8 (MyRocks), CockroachDB, TiKV, and many others. Every concept from our layer applies directly to RocksDBβs API.
PebbleDB (CockroachDB, 2019) is a Go implementation of RocksDBβs key ideas, designed for CockroachDBβs specific workload. CockroachDB switched from RocksDB to Pebble in 2021 for improved performance and simpler operations.
The common thread: all of these expose the same Get/Put/Delete/Batch/ Iterator/Snapshot interface. Building this interface over FDB means you
understand the contract deeply, because you have to implement it rather than
just use it.
10. Exercises β Build on This
These are not hypothetical. Each one adds a real capability:
Exercise 1 β Atomic Compare-And-Swap (CAS)
func (d *DB) CAS(key, expected, next []byte) (bool, error)
Read key, compare to expected, write next β all in one transaction. If the key changed since you read it, FDB retries automatically (conflict detection does this for you β you donβt need to loop).
Exercise 2 β TTL (Time-To-Live) Keys
Store expiry timestamps alongside values. Modify Get to return ErrNotFound
for expired keys, and add a Sweep() method that clears all expired entries
with a ClearRange.
Exercise 3 β Transactions Across Multiple Keys
tx := db.Begin()
tx.Put("account:alice:balance", "900")
tx.Put("account:bob:balance", "100")
tx.Commit()
This is just a Batch.Write today. Extend it to include optimistic reads
(read aliceβs balance, check itβs >= 100 before subtracting) β youβll need a
real Transact closure, not just a batch.
Exercise 4 β Prefix Scan
rows, err := db.Scan(prefix []byte) ([]KV, error)
Use RangeWithin(prefix, nil) and filter server-side. This is the basis
for the Record Layerβs ScanRecords.
Exercise 5 β Size Estimate
Use FDBβs GetEstimatedRangeSizeBytes (an atomic op) to estimate how many
bytes live in your subspace. This is how database engines implement SHOW TABLE STATUS without a full scan.
11. Source Code Deep Dive β Every Line Explained
This section walks through the full source of layer/db.go, layer/encoding.go, layer/iterator.go, and layer/snapshot.go with annotations about the non-obvious decisions.
db.go β The Core Database Type
type DB struct {
fdb fdb.Database
ns Subspace
}
Two fields. fdb is the FDB connection (goroutine-safe, long-lived). ns is the namespace: a byte prefix prepended to every key. Multiple DB instances on the same FDB cluster with different ns values are completely isolated β their key ranges do not overlap.
func Open(fdbDB fdb.Database, namespace []byte) *DB {
return &DB{fdb: fdbDB, ns: NewSubspace(namespace)}
}
Open does not contact FDB. Itβs a pure in-memory initialization. The connection to FDB was already established when fdb.OpenDefault() was called in main.go. Open just associates this DB with a prefix.
Why take fdb.Database rather than a cluster address string? This lets the caller decide how the FDB connection is configured (API version, cluster file path, network options) and share one connection across multiple DB instances. Multiple DB instances share one FDB network thread and one connection pool.
func (d *DB) FDB() fdb.Database { return d.fdb }
func (d *DB) Namespace() []byte { return d.ns.prefix }
Accessors for embedding and testing. FDB() lets a consumer pass the FDB connection to another layer (e.g., a Record Layer built on top of this DB). Namespace() lets tests inspect the key prefix.
encoding.go β The Full Subspace Implementation
func (s Subspace) RangeWithin(start, end []byte) fdb.KeyRange {
var begin, endKey fdb.Key
if start == nil {
begin = s.Range().Begin
} else {
begin = s.Pack(start)
}
if end == nil {
endKey = s.Range().End
} else {
endKey = s.Pack(end)
}
return fdb.KeyRange{Begin: begin, End: endKey}
}
RangeWithin lets callers specify a sub-range within the subspace. If start = nil, the range starts at the beginning of the subspace. If end = nil, the range ends at the end of the subspace. This is used by the iterator to implement LevelDBβs NewIterator(slice *util.Range) β an iterator over a restricted key range.
The subtle end encoding: When end is provided, we use Pack(end) as the upper bound. This is an exclusive upper bound in FDB (same as Pythonβs range() β GetRange(begin, end) returns keys where begin <= key < end). Since Pack(end) = prefix + 0x00 + end, this correctly excludes the end key itself.
iterator.go β Forward/Backward Navigation
func (it *Iterator) compareKeys(a, b []byte) int {
return bytes.Compare(a, b)
}
One line, but critical: key comparison is lexicographic byte order, not string collation, not numeric order. "9" > "10" under this comparison. This is identical to LevelDBβs default comparator (BytewiseComparator). If you need a different sort order, you need a sort-preserving encoding β which is why encodeInt64 (used in option-cβs index keys) exists.
func (it *Iterator) Seek(target []byte) {
for it.idx = 0; it.idx < len(it.kvs); it.idx++ {
if it.compareKeys(it.Key(), target) >= 0 {
return
}
}
}
Linear scan for Seek. Correct but O(n). A production implementation uses binary search:
it.idx = sort.Search(len(it.kvs), func(i int) bool {
return bytes.Compare(it.kvs[i].Key, target) >= 0
})
This is O(log n) β important for large result sets. The linear scan is kept here for readability.
snapshot.go β Pinning MVCC Versions
func (s *Snapshot) NewIterator(start, end []byte) *Iterator {
return newIteratorAt(s.db, s.ns, start, end, s.readVersion)
}
The iterator constructor takes readVersion and passes it to the internal newIteratorAt. Inside newIteratorAt:
func newIteratorAt(fdbDB fdb.Database, ns Subspace, start, end []byte, readVersion int64) *Iterator {
v, _ := fdbDB.Transact(func(tr fdb.Transaction) (interface{}, error) {
if readVersion > 0 {
tr.SetReadVersion(readVersion)
}
return tr.GetRange(ns.RangeWithin(start, end), fdb.RangeOptions{}).GetSliceWithError()
})
...
}
If readVersion > 0, we pin the transaction to that version. The range scan returns results as of that exact version. This enables snapshot-consistent iteration β the iterator sees a stable, non-changing view even while concurrent writers are active.
The Transact retry loop and SetReadVersion: Transact retries on conflict. But SetReadVersion sets a fixed read version β the transactionβs read set is pinned. Can we still get a conflict on a read-only operation that reads from a pinned version? No β conflicts are about write-write and read-write conflicts. A transaction that only reads (even via Transact) cannot be retried due to conflict unless it also writes. Our snapshot transactions only read, so Transact will not retry them for conflict reasons. The only reason for retry would be a transaction_too_old error (if readVersion is too old and the data has been GCβd), which surfaces as an error to the caller.
12. Production Considerations
12.1 FDB Transaction Size Limits
FDB enforces hard limits on transactions:
- 10 MB total mutation size (sum of all
SetandClearcalls in one transaction) - 10 MB total read size (sum of all values read)
- 5 seconds maximum transaction duration (from first operation to commit)
For option-a-leveldb, the most likely hit is the iterator: GetSliceWithError() reads the entire range into memory in one transaction. If a namespace contains 50 MB of data and you create an iterator over it, youβll get a transaction too-large error.
Production solution: Paginated iteration with cursors:
// Instead of reading everything at once:
var cursor fdb.Key = ns.Range().Begin
const pageSize = 10_000
for {
kvs, _ := db.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(fdb.KeyRange{Begin: cursor, End: ns.Range().End},
fdb.RangeOptions{Limit: pageSize}).GetSliceWithError()
})
if len(kvs) == 0 {
break
}
// process kvs...
cursor = fdb.Key(append(kvs[len(kvs)-1].Key, 0x00)) // next page starts after last key
}
12.2 FDB Go Binding Concurrency Model
The FDB Go binding uses a single-threaded network loop internally (the FDB C library has one network thread). All transactions are multiplexed over this thread. This means:
- Multiple goroutines can each have their own
ReadTransactorTransactcalls concurrently β these are correctly serialized by the binding fdb.Databaseis goroutine-safe- But the network thread is single-threaded: if you saturate it (thousands of concurrent transactions), youβll see latency increase
For high concurrency, FDB recommends batching multiple operations within one transaction rather than creating many small transactions.
12.3 Key Space Planning
Before deploying, decide on your namespace structure. Once data is in production, renaming a namespace (changing the prefix) requires migrating all data β a potentially days-long background job.
Good practice:
// Use a versioned namespace prefix
db := layer.Open(fdb, []byte("myapp:v1:users"))
// If schema changes require a new encoding:
newDB := layer.Open(fdb, []byte("myapp:v2:users"))
// Migrate in background; dual-write during migration window
12.4 Monitoring and Observability
FDB exposes cluster health via its status API:
fdbcli --exec "status json"
Key metrics to monitor:
- Transactions committed/second β throughput
- Conflicts/second β high values indicate hot keys or poorly structured transactions
- Storage server read/write latency β P99 should be < 10ms
- Data distribution lag β if a shard is being moved, latency spikes
For application-level monitoring, instrument every Transact call with a timer and tag the metric with the operation name.
13. Interview Questions β LevelDB, MVCC, and FDB
Q: What is the difference between ReadTransact and Transact in the FDB Go binding?
ReadTransact opens a read-only transaction: no write buffer, no conflict tracking, no commit phase. Itβs cheaper than Transact because it skips the commit round-trip. Use ReadTransact whenever youβre only reading. Transact opens a read-write transaction: it tracks the read key set for conflict detection and requires a commit round-trip to apply writes. For single-key reads like Get, using Transact instead of ReadTransact works but wastes latency and cluster resources.
Q: If FDBβs MVCC window is ~5 seconds, what happens to a snapshot older than 5 seconds?
Reading from that snapshot returns a transaction_too_old error. FDB garbage-collects old MVCC versions after the configured version history window (default ~5 seconds). Any transaction β including read-only snapshot reads β that tries to read a version older than the GC horizon fails with a retriable error. In our Snapshot implementation, this means that a snapshot held open longer than ~5 seconds will start returning errors on the next Get or NewIterator call. Production code must handle this by recreating the snapshot.
Q: LevelDB supports custom comparators. What would you need to change to support a different sort order in this layer?
The sort order is determined by FDBβs key ordering, which is always lexicographic byte order. To support a different sort order (e.g., βintegers sort numericallyβ), you would change the key encoding: encode integer keys using encodeInt64 (sign-bit-flipped big-endian) so that their byte order matches their numeric order. You cannot change FDBβs comparator; you can only change what bytes you write as keys. This is why sort-preserving encoding is the fundamental concept in layer design.
Q: How does FDBβs Transact retry loop interact with side effects?
Transact retries the closure function if the transaction conflicts. If the closure has side effects outside FDB (e.g., incrementing a counter, logging, sending an HTTP request), those side effects will execute multiple times on retry. The FDB convention is: the Transact closure must be idempotent or side-effect-free. For logging, use a separate post-commit hook. For counters, use FDB atomic operations (tr.Add) which are commutative and do not require retry logic.
Q: How does an iterator over a snapshot differ from an iterator over the current database state?
A snapshot iterator is pinned to the read version captured at NewSnapshot() time. Even if concurrent writers modify or delete keys between when the snapshot was taken and when the iterator is created, the iterator sees the state at snapshot time. A current-state iterator uses the latest committed version, so it sees all mutations up to the moment the GetRange call is sent to the cluster. In our implementation, the difference is one line: tr.SetReadVersion(readVersion) in the snapshot path.
Option A β βSQL Layerβ over FoundationDB
Pattern: relational engine above FDB. Tables β subspaces, rows β KV pairs, ACID inherited from FDB transactions. This is conceptually what Appleβs old (now-archived)
fdb-sql-layerdid.
What we ship (and donβt)
We ship the storage half of a SQL engine: a catalog, row encoding, table scans, primary-key lookup, and atomic inserts/updates/deletes β all in about 250 lines.
We do not ship a SQL parser. The Go API itself is the query language:
db.CreateTable(sqllayer.TableDef{Name:"users", PK:"id", Columns: ...})
db.Insert("users", sqllayer.Row{"id":1, "name":"Alice", "city":"Paris"})
db.SelectWhere("users", func(r sqllayer.Row) bool { return r["city"] == "Paris" })
A real SQL frontend would compile SELECT ... WHERE city='Paris' into
exactly the same backend call.
Key layout
<ns> 0x00 <tableName> -> msgpack(TableDef) [catalog]
<ns> 0x01 <tableName> 0x00 <encodedPK> -> msgpack(Row) [data]
<ns> 0x02 <tableName> -> uint64 (LE counter) [rowid seq]
- Catalog lets a fresh
Open()reconstruct the in-memory schema cache. - Data rows live under a per-table subspace, so
GetRangeon<ns> 0x01 <tableName> 0x00is a full table scan in key order. - Seq is used only when the table has no declared PK (we mint a rowid).
PK values are encoded with the same sign-flipping big-endian trick used in option-c-record-layer: integer rows sort numerically, strings sort lexicographically. This is what makes table scans produce ordered output βfor freeβ.
Transactionality
Insert does the seq read+write and the row write in the same
fdb.Transact, so two concurrent inserts can never collide on the same
rowid: FDB will retry one of them. DropTable clears catalog, all rows,
and the seq counter atomically β no orphan rows can be observed after a
drop.
Why this is not a real SQLite layer
A real SQLite vtab would let you write SELECT name FROM users WHERE city='Paris'
in standard SQL and have SQLiteβs planner call back into FDB. Building that
requires:
- Implementing the
sqlite3_moduleC ABI (via CGO with mattn/go-sqlite3, or usingmodernc.org/sqliteβs yet-undocumented vtab hooks). - Mapping SQLiteβs
xBestIndex/xFilter/xColumncallbacks onto FDB range scans. - Round-tripping SQLiteβs rowids to our PK encoding.
Thatβs an order of magnitude more code (~1.5k lines) and adds little to your understanding of FDB itself; the interesting parts β how does the relational model fit on top of an ordered KV store? β are all here.
Running
cd option-a-sqlite
go mod tidy
go run ./demo -cluster ../fdb.cluster
Expected output:
SELECT * FROM users;
map[city:Paris id:1 name:Alice]
map[city:Tokyo id:2 name:Bob]
map[city:Paris id:3 name:Carol]
SELECT * FROM users WHERE city='Paris';
map[city:Paris id:1 name:Alice]
map[city:Paris id:3 name:Carol]
After UPDATE users SET city='Tokyo' WHERE id=1;
map[city:Tokyo id:1 name:Alice]
map[city:Tokyo id:2 name:Bob]
map[city:Paris id:3 name:Carol]
After DELETE FROM users WHERE id=3;
map[city:Tokyo id:1 name:Alice]
map[city:Tokyo id:2 name:Bob]
Hitchhikerβs Guide β Option A: SQL Engine over FoundationDB
- Table of Contents
- 1. What a Relational Database Actually Stores
- 2. The Catalog β A Database About Databases
- 3. Row Encoding β Turning a Map into Bytes
- 4. Primary Keys and Sort Order
- 5. Full Table Scan β The Honest Query
- 6. INSERT as Rowid Allocation + Write
- 7. UPDATE = Overwrite (Why it Works)
- 8. DELETE = Clear (Why it Works)
- 9. Key Layout In Detail
- 10. What We Didnβt Build (and Why Those Parts Are Hard)
- 11. Real-World Analogues
- 12. Exercises
- 13. Source Code Deep Dive β sqllayer/db.go
- 14. Production Architecture β What a Real SQL-on-FDB Looks Like
- 15. Interview Questions β Relational Databases and FDB
The question this answers: βWhat does a relational database actually store? If you strip away the SQL parser and the query planner, what is left?β
The deeper question: βHow do tables, rows, primary keys, and catalogs map onto an ordered keyβvalue store?β
Table of Contents
- What a Relational Database Actually Stores
- The Catalog β A Database About Databases
- Row Encoding β Turning a Map into Bytes
- Primary Keys and Sort Order
- Full Table Scan β The Honest Query
- INSERT as a Rowid Allocation + Write
- UPDATE = Overwrite (Why it Works)
- DELETE = Clear (Why it Works)
- Key Layout In Detail
- What We Didnβt Build (and Why Those Parts Are Hard)
- Real-World Analogues: SQLite, MySQL, PostgreSQL
- Exercises
1. What a Relational Database Actually Stores
A relational database is, at its core, a system for:
- Storing rows (collections of typed values) under a primary key.
- Keeping a catalog (metadata about what tables exist and their schemas).
- Maintaining indexes (secondary data structures for fast lookup by non-primary-key fields).
- Executing queries (finding rows matching a predicate).
Strip away SQL parsing, the query planner, authentication, and network protocols, and you have a structured keyβvalue store with a catalog.
Our layer implements steps 1 and 2. Step 3 is in option-c-record-layer.
Step 4 is a SelectWhere(pred) that does a full scan β honest but slow,
which makes the need for indexes obvious.
2. The Catalog β A Database About Databases
Every real database has a catalog: a table of tables. PostgreSQL stores
it in pg_catalog.pg_class. MySQL uses information_schema.TABLES. SQLite
writes the catalog to the first page of the database file as sqlite_master.
Our catalog lives in FDB under a dedicated subspace:
<ns> 0x00 <tableName> β msgpack(TableDef)
TableDef contains the table name, column definitions (name + type), and the
declared primary-key column name (if any). When Open() is called, we scan
this subspace and reconstruct the in-memory schemas map:
func (d *DB) loadCatalog() error {
// range-scan the catalog subspace
kvs, _ := d.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(d.catalogRange(), fdb.RangeOptions{}).GetSliceWithError()
})
for _, kv := range kvs {
var def TableDef
msgpack.Unmarshal(kv.Value, &def)
d.schemas[def.Name] = def
}
}
This means the catalog is self-describing β a fresh process connecting to
an existing FDB namespace automatically discovers all tables. You donβt need
to pass schemas at startup. This mirrors how SQLite opens a database file and
reads its catalog from sqlite_master before accepting any queries.
CreateTable is atomic: the catalog write and any initial setup happen
inside one FDB transaction. Two concurrent CreateTable("users") calls will
either both succeed (if they have identical schemas) or one will fail with a
conflict. There is no possibility of a half-created table.
3. Row Encoding β Turning a Map into Bytes
Our Row type is map[string]any. We serialize it with msgpack, which
produces a compact binary representation:
{"name": "Alice", "city": "Paris", "id": 1}
β 83 a4 6e 61 6d 65 a5 41 6c 69 63 65 a4 63 69 74 79 a5 50 61 72 69 73 a2 69 64 01
(msgpack map: 3 entries, name="Alice", city="Paris", id=1)
The row bytes become the value in FDB. The key is derived from the primary key value (see below).
Why msgpack, not JSON or Protobuf?
- JSON is human-readable but wastes space (quoted keys, text numbers).
- Protobuf is compact but requires a schema file and code generation.
- msgpack is compact, self-describing (no external schema), and has first- class Go support.
For a teaching implementation, msgpack hits the right trade-off. For production, Protobuf or Capβn Proto would be better choices, since they support schema evolution (adding fields without breaking old readers) and are more efficient for fixed schemas.
4. Primary Keys and Sort Order
A primary key must be unique and determines the storage order of rows.
In FDB, βstorage orderβ = βkey byte orderβ. So we need to encode primary key values into bytes in a way that preserves the intended sort order.
String PKs: store as raw UTF-8 bytes. UTF-8βs byte ordering preserves lexicographic order for ASCII characters. Enough for a demo; a production system would use Unicode collation.
Integer PKs: this is where it gets interesting.
Naive approach: encode as decimal string. Problem: "9" > "30" in byte
comparison. Rows would sort as: 1, 10, 100, 2, 20, 3β¦ β wrong.
Better: big-endian 64-bit integer. Problem: negative numbers have the high bit
set, so -1 encodes as 0xFF... which sorts after all positive numbers.
Our solution (and the industry standard):
func encodeInt64(x int64) []byte {
b := make([]byte, 8)
// Flip the sign bit. This turns the two's-complement representation into
// an unsigned representation that sorts correctly:
// 0 β 0x8000_0000_0000_0000 (sorts between negatives and positives)
// 1 β 0x8000_0000_0000_0001
// -1 β 0x7FFF_FFFF_FFFF_FFFF (sorts before 0)
// -2 β 0x7FFF_FFFF_FFFF_FFFE (sorts before -1)
binary.BigEndian.PutUint64(b, uint64(x)^(1<<63))
return b
}
After this encoding, integer byte strings sort in the same order as their
numeric values. A GetRange on the rows subspace returns rows in ascending
PK order, for free, by FDBβs native byte-order scan.
This is the same trick used by:
- Apache HBase (for row key ordering)
- Google Cloud Bigtable
- FoundationDBβs official Tuple layer (for negative integers)
- Apache Cassandraβs blob serialization
5. Full Table Scan β The Honest Query
SelectAll and SelectWhere both scan the entire rows subspace:
func (d *DB) SelectWhere(table string, pred func(Row) bool) ([]Row, error) {
v, _ := d.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(d.tableRange(table), fdb.RangeOptions{}).GetSliceWithError()
})
for _, kv := range v.([]fdb.KeyValue) {
var row Row
msgpack.Unmarshal(kv.Value, &row)
if pred == nil || pred(row) {
out = append(out, row)
}
}
}
The predicate is applied in Go, after fetching all rows from FDB. This is
a full table scan β the same operation as SELECT * FROM users WHERE city='Paris' without any index.
Why not push the predicate to FDB?
FDB has no server-side filtering. Its API is: βgive me this key range.β All filtering must happen on the client. This is a deliberate design choice by FDB: the cluster is a dumb-fast KV store; smarts live in the layer.
Real SQL databases push predicates βdownβ to the storage engine to avoid fetching unnecessary rows over the network. In our setup, βthe networkβ is the connection to FDBβs storage servers β for large tables, a full scan incurs real latency. This is the motivation for secondary indexes (option-c).
What a real SQL engine does instead:
A query planner looks at the predicate city='Paris' and checks whether there
is an index on city. If yes, it rewrites the scan as:
scan index(city = 'Paris') β [pk1, pk2, pk3]
fetch rows by pk: Get(pk1), Get(pk2), Get(pk3)
This is exactly what LookupByIndex does in option-c.
6. INSERT as Rowid Allocation + Write
When a table has no declared PK, we allocate a monotonic rowid:
_, err := d.fdb.Transact(func(tr fdb.Transaction) (interface{}, error) {
curBytes, _ := tr.Get(d.seqKey(table)).Get()
var cur uint64
if len(curBytes) == 8 {
cur = binary.LittleEndian.Uint64(curBytes)
}
next := cur + 1
// Write the new counter value
binary.LittleEndian.PutUint64(buf, next)
tr.Set(d.seqKey(table), buf)
// Write the row
tr.Set(d.rowKey(table, encodeInt64(int64(next))), encoded)
return nil, nil
})
The counter read + increment + row write happen in one transaction. Two concurrent inserts cannot get the same rowid β FDBβs conflict detection will cause one to retry with a fresh counter read.
This is identical to how SQLite allocates its rowid (a monotonic integer
stored in the B-tree page header) and how PostgreSQL allocates OIDs and
ctids. The key insight: the counter is a row, and updating it is a
transaction.
7. UPDATE = Overwrite (Why it Works)
LevelDB and FDB share a property: Set(key, newValue) on an existing key
simply replaces it. There is no βupdate in placeβ at the storage level.
Our βUPDATEβ is just another Insert call with the same PK:
db.Insert("users", Row{"id": 1, "name": "Alice", "city": "Tokyo"})
// This calls tr.Set(rowKey(table, encode(1)), msgpack(new_row))
// The old row bytes under the same key are replaced.
FDB does not copy the old value to a TOAST or undo segment β it just writes the new value. The old version is kept in FDBβs MVCC history for ~5 seconds (for in-flight read transactions) and then garbage-collected.
What changes when you have indexes:
Once you have secondary indexes (option-c), an update is no longer just a single key write. You must:
- Read the old row (to find out what old index entries to remove)
- Clear the old index entries
- Write the new row
- Write the new index entries
All in one transaction. This is why option-cβs PutRecord is more complex
than our Insert.
8. DELETE = Clear (Why it Works)
func (d *DB) Delete(table string, pk any) error {
encodedPK, _ := encodePK(pk)
_, err := d.fdb.Transact(func(tr fdb.Transaction) (interface{}, error) {
tr.Clear(d.rowKey(table, encodedPK))
return nil, nil
})
return err
}
Clear adds a tombstone to the transaction buffer. After commit, the key
disappears from FDB as if it was never written. Future reads return nil.
No cascades: unlike SQL with ON DELETE CASCADE, our engine has no
foreign key enforcement. This is an intentional simplification. Real SQL
engines resolve cascades by finding related rows (via indexes on the FK column)
and deleting them in the same transaction.
What about DropTable?
func (d *DB) DropTable(name string) error {
_, err := d.fdb.Transact(func(tr fdb.Transaction) (interface{}, error) {
tr.Clear(d.catalogKey(name)) // remove from catalog
tr.ClearRange(d.tableRange(name)) // delete all rows
tr.Clear(d.seqKey(name)) // reset rowid counter
return nil, nil
})
}
One transaction that deletes the catalog entry, all data rows, and the sequence counter. Atomically. A reader that has an open transaction might still see the table (MVCC), but any new transaction started after commit sees the table as gone.
9. Key Layout In Detail
Letβs trace exactly what happens when you call:
db.CreateTable(TableDef{Name: "users", PK: "id", Columns: [...]})
db.Insert("users", Row{"id": 1, "name": "Alice", "city": "Paris"})
db.Insert("users", Row{"id": 2, "name": "Bob", "city": "Tokyo"})
With namespace "demo-sql":
Catalog key:
"demo-sql" + 0x00 + "users"
= 64 65 6d 6f 2d 73 71 6c 00 75 73 65 72 73
β value: msgpack({Name:"users", PK:"id", Columns:[...]})
Row key for id=1:
"demo-sql" + 0x01 + "users" + 0x00 + encodeInt64(1)
= 64 65 6d 6f 2d 73 71 6c 01 75 73 65 72 73 00 80 00 00 00 00 00 00 01
β encoded 1 (sign bit flipped)
β value: msgpack({id:1, name:"Alice", city:"Paris"})
Row key for id=2:
"demo-sql" + 0x01 + "users" + 0x00 + 80 00 00 00 00 00 00 02
β value: msgpack({id:2, name:"Bob", city:"Tokyo"})
Table scan range:
begin: "demo-sql" + 0x01 + "users" + 0x00
end: "demo-sql" + 0x01 + "users" + 0x01
β returns id=1 row, then id=2 row (in ascending id order)
The 0x00 separator between tag, table name, and PK ensures:
- Table βuserβ doesnβt collide with table βusersβ
- Row key doesnβt collide with catalog key (different tag byte: 0x01 vs 0x00)
- Rows for table βusersβ donβt collide with rows for table βordersβ (different table name segment)
10. What We Didnβt Build (and Why Those Parts Are Hard)
Secondary indexes:
We have SelectWhere but it does a full scan. Adding a secondary index
(e.g., βfind all users where city=βParisββ) requires maintaining an extra
key for every row that maps (city value, pk) β "". The hard part is
keeping the index consistent when rows change β see option-c.
SQL parsing:
A SQL parser turns SELECT name FROM users WHERE city='Paris' into an AST.
This is ~1000 lines of code for a minimal SQL grammar. We skip it because
it adds no FDB-specific insight.
Query planner: Given multiple indexes, which one should we use? This is a graph problem (estimating cost of each query plan) that modern databases spend enormous effort on. The basic version is simple (pick the most selective index), but getting it right for all cases is a career.
JOIN: Joining two tables in SQL is conceptually a nested loop (for each row in table A, find matching rows in table B). The hard part is making this fast when both tables are large. Most JOIN algorithms (hash join, sort-merge join) still reduce to: βscan one table, look up matching rows from the other.β
MVCC for uncommitted readers:
We provide MVCC through FDBβs read versions, but we donβt expose
BEGIN TRANSACTION / COMMIT / ROLLBACK at the SQL level. Adding this
requires keeping an open FDB transaction for the duration of the user
transaction β which is possible but requires careful lifecycle management.
11. Real-World Analogues
SQLite
SQLite stores its catalog in sqlite_master, a special table on page 1:
CREATE TABLE sqlite_master (
type TEXT, -- "table", "index", "view", "trigger"
name TEXT,
tbl_name TEXT,
rootpage INTEGER, -- page number of the B-tree root for this table
sql TEXT -- the original CREATE statement
);
Every tableβs rows are stored in a B-tree rooted at rootpage. The B-tree
keys are the rowid values (64-bit integers, always). The row data (all
columns) is packed into the B-tree leaf value using SQLiteβs own compact
encoding.
Our rowKey(table, encodedPK) is the FDB equivalent of the B-tree rowid.
Our msgpack row serialization is the FDB equivalent of SQLiteβs row record
format.
MySQL InnoDB
InnoDB uses a clustered index β the primary key IS the B-tree. All columns are stored in the PK leaf node. Secondary indexes store the PK value (not the physical row location) as their βpointerβ:
PRIMARY KEY index: pk β all_columns
SECONDARY INDEX on city: city β pk
To look up a row by city:
- Scan secondary index for
city = 'Paris'β get pk values - Look up each pk in the primary index β get the full row
This two-step lookup is called a βkey lookupβ in MySQLβs EXPLAIN output. Our option-c-record-layer does exactly this.
PostgreSQL
PostgreSQL uses βheap filesβ β rows are stored in pages in insertion order,
not sorted by PK. The PK is just another index (a B-tree) that maps
pk β ctid (page number + slot within page). Secondary indexes also map
field_value β ctid.
This is subtly different from InnoDB: in PostgreSQL, all indexes point to physical page locations, not to the PK. This makes index maintenance easier (an update to a non-indexed column doesnβt touch any index), but makes VACUUM (garbage collection of old row versions) more complex.
CockroachDB / TiKV / YugabyteDB
These βNewSQLβ databases are essentially what weβre building here: a SQL query engine on top of a distributed, ordered, transactional KV store. Their storage architecture is:
SQL layer (parsing, planning, execution)
β
Encoding layer (rows β keys, indexes β keys)
β
Distributed KV layer (RocksDB + Raft replication per shard)
CockroachDBβs row encoding is:
/<table_id>/<index_id>/<col1_value>/<col2_value>/... β rest_of_columns
This is the same pattern as our <ns>/<tag>/<table_name>/<pk> β just with
more type information and a smarter Tuple encoding.
12. Exercises
Exercise 1 β Add a Secondary Index
Modify Insert to also write index entries:
ns + 0x02 + tableName + 0x00 + columnName + 0x00 + encodedValue + 0x00 + pk β ""
Modify Delete to also clear those entries (requires reading the row first).
Add SelectByIndex(table, column, value) ([]Row, error).
Exercise 2 β Foreign Keys
Add a References field to Column:
type Column struct {
Name string
Type ColumnType
References *ForeignKey // nil if no FK
}
In Insert, check that the referenced PK exists (a Get inside the same
transaction). In Delete, check that no child rows reference this PK (a
GetRange on the FK index).
Exercise 3 β Transactions for Callers
Add a Begin() *Tx method that returns a transaction object. Tx should
buffer operations and commit on tx.Commit() β all inside one FDB
db.Transact() call.
Exercise 4 β DISTINCT and COUNT
Implement SelectDistinct(table, column) and Count(table) using a single
FDB GetRange and in-process aggregation. This is your first aggregation
operator β the same one query engines implement with hash tables or sort passes.
Exercise 5 β Schema Evolution
Add a version number to TableDef. When Open() loads a table with version
< current, run a migration function that adds default values for new columns.
13. Source Code Deep Dive β sqllayer/db.go
The entire engine is in sqllayer/db.go. Letβs trace the critical paths.
The DB Type
type DB struct {
fdb fdb.Database
ns []byte
schemas map[string]TableDef
mu sync.RWMutex
}
schemas is an in-memory cache of the catalog. It is loaded at Open() time from FDB and updated by CreateTable/DropTable. The mutex protects concurrent access to schemas.
Design question: Why cache schemas in memory at all? Because every query needs the schema to know column types (for encoding/decoding) and the PK column name. Fetching the schema from FDB on every query would add a round-trip. The trade-off: the in-memory cache can be stale if another process creates/drops a table. Production systems solve this with schema versioning: every DDL operation increments a global version; processes re-sync when they detect a version mismatch.
Key Helpers
func (d *DB) catalogKey(table string) fdb.Key {
return fdb.Key(append(append([]byte{}, d.ns...), append([]byte{0x00}, []byte(table)...)...))
}
func (d *DB) rowKey(table string, encodedPK []byte) fdb.Key {
prefix := append(append([]byte{}, d.ns...), 0x01)
prefix = append(prefix, []byte(table)...)
prefix = append(prefix, 0x00)
return fdb.Key(append(prefix, encodedPK...))
}
Tag bytes distinguish subspaces:
0x00β catalog entries0x01β row data0x02β sequence (rowid counter)
This tag-byte approach is simpler than the full Tuple encoding but achieves the same goal: prevent catalog keys from colliding with row keys even if a table is named βusersβ and another subspace prefix also uses βusersβ.
The Sequence Counter Pattern
func (d *DB) nextRowid(tr fdb.Transaction, table string) (int64, error) {
key := d.seqKey(table)
cur, _ := tr.Get(key).Get()
var n int64 = 1
if len(cur) == 8 {
n = int64(binary.LittleEndian.Uint64(cur)) + 1
}
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, uint64(n))
tr.Set(key, b)
return n, nil
}
This is a read-modify-write within one transaction. The caller passes tr (an active transaction). The Get reads the current counter value. The Set increments it. Both are in the same transaction as the row write.
If two concurrent inserts both read the same counter value, theyβll both try to write n+1 β but FDBβs conflict detection will catch this: both transactions read the same key, and both tried to write it. One will conflict and retry, which re-reads the counter and gets a fresh value. The retry is invisible to the caller.
Why not use FDBβs atomic add?
tr.Add(key, encodeInt64(1)) // atomic increment, no conflict
tr.Add is a commutative atomic operation β it doesnβt cause read-write conflicts. But it returns no value β you canβt know what rowid was assigned. If you need to use the rowid in the same transaction (to build the row key), you need the read-modify-write pattern. If the rowid is just an opaque row identifier that callers donβt use, tr.Add plus a versionstamp (to create a unique, ordered key without knowing the exact value) is a better approach.
The encodePK Function
func encodePK(v any) ([]byte, error) {
switch x := v.(type) {
case int:
return encodeInt64(int64(x)), nil
case int64:
return encodeInt64(x), nil
case string:
return []byte(x), nil
case []byte:
return x, nil
default:
return nil, fmt.Errorf("unsupported PK type %T", v)
}
}
String PKs are stored as raw UTF-8 bytes. This sorts correctly for ASCII but not for Unicode (e.g., βΓ©β sorts after βzβ in byte order but before βzβ in French locale). For a production system, use the ICU library for locale-aware collation, or restrict PKs to ASCII.
Integer PKs use encodeInt64: big-endian with sign-bit flipped. Read Chapter 3 of this guide for the derivation.
What about composite PKs? (user_id, order_id) as a PK means the row key is encode(user_id) + 0x00 + encode(order_id). The separator between components is 0x00 (the Tuple encoding style). This naturally makes rows for the same user adjacent in key order β a range scan GetRange(encode(user_id) + 0x00, encode(user_id) + 0x01) returns all orders for that user. Our implementation only supports single-column PKs, but the extension is straightforward.
14. Production Architecture β What a Real SQL-on-FDB Looks Like
The five components of a production SQL-on-FDB system:
Component 1: SQL Parser
Takes SQL text, produces an AST. For SELECT name FROM users WHERE city='Paris':
SelectStatement {
Columns: ["name"],
Table: "users",
Where: EqualExpr{Column: "city", Value: "Paris"},
}
Libraries: pingcap/tidb/parser (Go, production-grade), xwb1989/sqlparser (Go, simpler), or write your own using gocc or pigeon.
Component 2: Query Planner
Takes the AST, looks at available indexes, estimates row counts, and picks the cheapest plan:
Without index on city:
plan: TableScan("users") β Filter(city='Paris')
cost: O(n_rows) reads
With index on city:
plan: IndexScan(city='Paris') β PKLookup("users", [pk1, pk2, ...])
cost: O(index_entries) + O(result_rows) reads
The planner is the hardest part of a query engine. Modern planners use statistics (histograms of column value distributions) stored in a catalog table to estimate n_rows for each plan.
Component 3: Encoding Layer (what we built)
Rows β keys in FDB. Indexes β keys in FDB. All with sort-preserving encodings.
Component 4: FDB Transaction Management
SQL has BEGIN / COMMIT / ROLLBACK. FDB has Transact. Bridging these requires holding an FDB Transaction object open for the duration of the SQL transaction. The FDB 5-second limit means long-running SQL transactions must be broken up or use FDBβs βgrvβ caching.
Component 5: Network Protocol
Postgres wire protocol (libpq-compatible), MySQL wire protocol, or a custom protocol. This is what lets standard clients (psql, JDBC, SQLAlchemy) connect. CockroachDB implements the Postgres wire protocol on top of their FDB-like storage.
15. Interview Questions β Relational Databases and FDB
Q: What is a clustered index, and how does option-a-sqlite implement it?
A clustered index is an index where the data rows are stored at the index leaf nodes, sorted by the index key. In option-a-sqlite, the FDB key for each row IS the primary key, and the row data is the value at that key. Range scanning by primary key returns rows in PK order and fetches the data in the same operation. This is a clustered index: the data is organized by PK. InnoDB uses the same design (the primary key B-tree is the table).
Q: What happens when two concurrent transactions try to insert a row with the same primary key?
Both transactions read the same row key (which doesnβt exist yet β they get nil). Both write a new value to that key. FDBβs conflict detection: one transaction commits; the other conflicts (it wrote to a key that was also written by the committed transaction). The conflicting transaction retries from the beginning. On retry, it reads the row key again β this time it exists. Depending on application semantics, the retry can: (a) return a duplicate key error, (b) return the existing row (SELECT or INSERT/IGNORE), or (c) replace it (INSERT OR REPLACE).
Q: Why does PostgreSQL store rows in heap order rather than clustered by PK?
PostgreSQLβs original design assumed that MVCC updates would frequently need to move rows (updating a row creates a new row version at a new physical location; the old version stays until VACUUM). If rows were clustered by PK, a moved row would need to update all indexes to point to the new physical location. By using heap files with stable physical locations (ctid), updates only create a new heap entry and update the visibility chain β indexes donβt need to change. The trade-off: PK range scans in PostgreSQL require index traversal + random I/O into the heap, while InnoDBβs clustered index gives sequential I/O for PK range scans.
Q: How does your row encoding handle schema migrations β adding a new column to a table with existing rows?
With msgpack, adding a new column to the schema is safe for new rows (they include the new column). Old rows, when read back, will simply not have the column in the map. Application code should handle missing fields with defaults. This is βforward compatibilityβ: old data is compatible with new code. The hard case is βbackward compatibilityβ: if you need all rows to have the new column (for a NOT NULL constraint), you must run a background migration job that reads every old row, adds the default value, and writes it back β one paginated batch per transaction.
This is how every production database handles ALTER TABLE ADD COLUMN.
Option B β LevelDB on top of FoundationDB
- How LevelDB sees the world
- Key layout
- Why this is interesting
- Running
- What this implementation skips
Pattern: existing storage engine, FDB as the disk. We give the unmodified
goleveldblibrary an FDB-backed implementation of itsstorage.Storageinterface. LevelDB still does its LSM thing (memtables, SSTables, compaction, MANIFEST), but every byte ends up in FDB key ranges instead of on local disk.
This is the mirror image of option-a: the storage engine sits below FDB rather than above it.
How LevelDB sees the world
goleveldb accesses persistence exclusively through a small interface:
type Storage interface {
Lock() (Locker, error)
Log(str string)
SetMeta(FileDesc) error
GetMeta() (FileDesc, error)
List(FileType) ([]FileDesc, error)
Open(FileDesc) (Reader, error)
Create(FileDesc) (Writer, error)
Remove(FileDesc) error
Rename(old, new FileDesc) error
Close() error
}
Each FileDesc is {Type, Num} β e.g. {TypeTable, 42} for SST #42 or
{TypeManifest, 7} for the 7th manifest. Filenames are an implementation
detail; LevelDB never looks at strings.
Our fdbstorage package implements that interface against FDB. The whole
file is ~250 lines.
Key layout
<ns> 0x01 <ftype:1B> <num:int64 BE> -> uint64 BE file size
<ns> 0x02 <ftype:1B> <num:int64 BE> <chunk:uint32 BE> -> 64 KiB chunk
<ns> 0x03 -> current MANIFEST {ftype,num}
<ns> 0x04 -> lock marker
- Files are split into 64 KiB chunks so we stay well under FDBβs 100 KiB-per- value soft limit and the 10 MB-per-transaction hard limit.
Createreturns aWriterthat buffers in memory and flushes onSync/Close. We split the flush across multiple transactions (100 chunks each β 6 MiB) to safely handle files larger than 10 MB.Renameis implemented as copy-then-clear inside one transaction. LevelDB only renames small files (temp β real on flush completion), so the inefficiency doesnβt matter.SetMetawrites the manifest pointer atomically. Because FDB transactions are serializable, two concurrent flushes canβt observe a half-rotated manifest.
Why this is interesting
You get a real LevelDB instance β with bloom filters, compaction, snapshots, the works β whose durability story is βwhatever FDBβs durability story is.β That means:
- Geo-replication and read scaling come for free from the FDB cluster.
- Backups are FDB backups.
- The local node has no on-disk state at all; it can crash and restart against a different FDB coordinator without losing anything.
The cost is latency: every SST read is at least one FDB round-trip, every flush is many. This isnβt a production architecture; itβs a teaching artifact that proves how cleanly the layers separate.
Running
cd option-b-leveldb
go mod tidy
go run ./demo -cluster ../fdb.cluster
Expected output (the second session re-opens and reads the persisted data):
First session: wrote 3 keys, then closed.
Reopening LevelDB on the same FDB namespace...
apple -> red
banana -> yellow
cherry -> red
Iterating the whole DB:
apple -> red
banana -> yellow
cherry -> red
What this implementation skips
- Locker isnβt multi-process safe across long-lived processes β if a holder crashes the lock key stays set. A production version would attach the lock to a client UUID and TTL it via FDB watches.
- Reader loads the whole file into memory. LevelDB SSTs are bounded (default 2 MB), so this is fine for a demo but not for huge tables.
- No caching layer. Every
Openis a fresh FDB scan. A real impl would cache hot SSTs.
Read fdbstorage/storage.go β the whole thing is one file deliberately, so you can follow the data flow end to end.
Hitchhikerβs Guide β Option B: LevelDB on FDB Storage
- Table of Contents
- 1. The Storage Abstraction: Why LevelDB Has a Plugin Point
- 2. What LevelDB Actually Writes to Disk
- 3. The storage.Storage Interface β Dissected
- 4. How We Map LevelDB Files to FDB Keys
- 5. Key Layout Deep Dive: From Function Call to FDB Bytes
- 5.1 The Four Tag Bytes
- 5.2 Anatomy of Every Key Type
- 5.3 Journey:
db.Put("hello", "world")β FDB keys - 5.4 Journey:
stor.Open(FileDesc{TypeJournal, 2}) - 5.5 Journey:
stor.List(TypeAll) - 5.6 Journey:
stor.SetMeta(FileDesc{TypeManifest, 1}) - 5.7 What the FDB keyspace looks like for a small database
- 5.8 Verifying with a test
- 5.9 Live Simulation β Annotated Output
- 6. Chunking: Overcoming the 100 KiB Value Limit
- 7. Atomic Rename β Durabilityβs Secret Weapon
- 8. The Writer: Batching Chunks into Transactions
- 9. Why the WAL is Redundant With FDB
- 10. The Blob Layer Pattern
- 11. Real-World Analogues
- 12. Exercises
- 13. Source Code Deep Dive β fdbstorage/storage.go
- 14. Production Considerations
- 15. Interview Questions β Storage Abstractions and LSM Trees
- 16. Bugs Encountered and Lessons Learned
- Bug 1 β
storage.ErrNotExistDoes Not Exist - Bug 2 β
GetMetaReturning a Custom Error on Empty Namespace - Bug 3 β
List()Always Returned Empty - Bug 4 β FDB-Persisted Lock Survives Process Crashes
- Bug 5 β
os.IsNotExistIgnores Custom.Is()Methods (Go 1.16+) - Bug 6 β Stale FDB State Across Runs
- Bug 1 β
The question this answers: βCan I run a real LSM-tree storage engine β the actual LevelDB binary with all its compaction logic β with its files stored in FoundationDB instead of a local disk?β
The deeper question: βWhat is the
storage.Storageinterface, why does it exist, and what does it tell us about how databases handle file I/O?β
Table of Contents
- The Storage Abstraction: Why LevelDB Has a Plugin Point
- What LevelDB Actually Writes to Disk
- The storage.Storage Interface β Dissected
- How We Map LevelDB Files to FDB Keys
- Key Layout Deep Dive: From Function Call to FDB Bytes
- Chunking: Overcoming the 100 KiB Value Limit
- Atomic Rename β Durabilityβs Secret Weapon
- The Writer: Batching Chunks into Transactions
- Why the WAL is Redundant With FDB
- The Blob Layer Pattern
- Real-World Analogues: RocksDB on Cloud Storage
- Exercises
- Source Code Deep Dive β fdbstorage/storage.go
- Production Considerations
- Interview Questions β Storage Abstractions and LSM Trees
- Bugs Encountered and Lessons Learned
1. The Storage Abstraction: Why LevelDB Has a Plugin Point
LevelDBβs storage.Storage interface exists because the original LevelDB
authors (Jeff Dean and Sanjay Ghemawat) designed it for portability. Not
every environment has a POSIX filesystem. Google has internal systems where
storage might be Bigtable, Colossus, or a custom log-structured store.
The interface says: βif you can implement these 8 methods, LevelDB will run on your storage.β The application code (goleveldb) doesnβt know whether itβs writing to ext4, NTFS, GCS, or FDB β it just calls the interface.
type Storage interface {
Lock() (util.Releaser, error)
Log(m storage.FileDesc) (storage.Writer, error)
Open(fd storage.FileDesc) (storage.Reader, error)
Create(fd storage.FileDesc) (storage.Writer, error)
Remove(fd storage.FileDesc) error
Rename(oldfd, newfd storage.FileDesc) error
GetMeta() (storage.FileDesc, error)
SetMeta(fd storage.FileDesc) error
List() ([]storage.FileDesc, error)
Close() error
}
Our fdbstorage.Storage implements all of these, storing files as FDB
key-value pairs. LevelDB itself (in the syndtr/goleveldb package) calls
these methods. It has no idea the βfilesβ are actually chunks in a distributed
database.
2. What LevelDB Actually Writes to Disk
To understand what we need to store, letβs look at what LevelDB writes:
File types:
TypeJournal (.log) β Write-Ahead Log: records every write before the
MemTable is flushed. Used to recover unflushed writes
after a crash.
TypeManifest (.MANIFEST) β Lists which SSTables are "live" (not yet
garbage-collected). Updated at each compaction.
TypeTable (.ldb / .sst) β Sorted String Tables. Immutable, sorted KV
data files produced by compaction.
TypeCurrent (CURRENT) β A single file containing the name of the latest
MANIFEST file.
TypeTemp (.tmp) β Temporary files used during compaction.
TypeLock (LOCK) β A file held open to prevent two processes from
opening the same database simultaneously.
File descriptor:
type FileDesc struct {
Type FileType // TypeJournal, TypeManifest, etc.
Num int64 // unique file number (monotonically increasing)
}
A LevelDB database directory looks like:
000003.log β journal (WAL)
000004.ldb β SSTable level 0
000005.ldb β SSTable level 0
MANIFEST-000002 β current manifest
CURRENT β "MANIFEST-000002\n"
LOCK β lockfile
When compaction happens:
- LevelDB picks some SSTables, merges and sorts them into a new SSTable.
- It writes the new SSTable as a
.tmpfile (viaCreate(TypeTemp, ...)) - It renames the
.tmpto the final.ldbname (viaRename) - It updates the MANIFEST to list the new SSTable and de-list the old ones.
- It removes the old SSTables (via
Remove).
This is the temp-then-rename durability pattern: create a new file
atomically, then rename it into place. POSIX rename is atomic β the old
name or the new name is visible, never a partial file. Our FDB implementation
must replicate this property.
3. The storage.Storage Interface β Dissected
Letβs look at each method and what it does:
Lock() (util.Releaser, error)
Prevents two processes from opening the same database simultaneously. We
implement this by writing a βlockβ key to FDB. The Releaser clears it.
Create(fd FileDesc) (Writer, error) and Open(fd FileDesc) (Reader, error)
Create starts a new file (for writing). Open opens an existing file (for
reading). In FDB terms: Create returns a writer that buffers bytes; Open
reads all chunks for the file into memory and returns a bytes.Reader.
Remove(fd FileDesc) error
Deletes a file. In FDB: ClearRange over all chunk keys for this file.
Rename(oldfd, newfd FileDesc) error
Renames a file atomically. In FDB: copy all chunks from oldfd keys to
newfd keys, then clear all oldfd keys β in one transaction. This is the
atomic rename.
GetMeta() (FileDesc, error) and SetMeta(fd FileDesc) error
Get/set the βcurrentβ file pointer β which MANIFEST is current. In FDB:
a single key (ns + tagManifest + 0x00) stores the current FileDesc.
This replaces the CURRENT file in LevelDBβs original design.
List() ([]FileDesc, error)
List all files. We implement this as a range scan over the meta key prefix.
We store a meta key for each file alongside its data.
3.1 There Are No Filesystem Calls in the Data Path
This is the most important thing to understand about the implementation. LevelDB calls our methods thinking it is talking to a real filesystem. But inside every method, instead of OS syscalls, we make FDB transactions.
// What a normal disk-backed implementation would do:
os.Open(path) // syscall β reads from disk
os.Create(path) // syscall β writes to disk
// What fdbstorage does instead:
rt.Get(metaKey(fd)) // FDB read: does this file exist?
rt.GetRange(dataRange(fd)).GetSliceWithError() // FDB read: fetch all chunks
tr.Set(metaKey(fd), sizeBytes) // FDB write: file metadata
tr.Set(chunkKey(fd, i), chunk) // FDB write: file content
The only os package usage in the entire file is os.ErrNotExist β borrowed
for its error semantics. There is no os.Open, no os.Create, no os.Read,
no os.Write, no file descriptor, no inode. The filesystem is entirely
replaced by the key naming scheme.
The exact translation points are:
| LevelDB expects | We return |
|---|---|
Create(fd) β io.WriteCloser | &writer{buf: bytes.Buffer{}} β writes go to memory |
w.Sync() / w.Close() | flush() β this is the first and only FDB write |
Open(fd) β io.ReadSeeker | one FDB ReadTransact β bytes.NewReader(buf) |
Remove(fd) | tr.Clear + tr.ClearRange in one transaction |
Rename(old,new) | copy all keys + clear old keys in one transaction |
4. How We Map LevelDB Files to FDB Keys
4.1 FileDesc: (Type, Num) β Not a Filename
LevelDB never uses string filenames internally. Every file is a
FileDesc struct β just two numbers:
type FileDesc struct {
Type FileType // what category of file
Num int64 // which one β a counter, never reused
}
The String() method (MANIFEST-000001, 000002.log, 000003.ldb) is a
display-only format for humans and log messages. Those strings never appear
in FDB keys β only the raw integer values do.
Num is assigned by LevelDBβs internal file number counter, which only ever
increases. If file 3 is deleted and a new SSTable is created, it gets number
5, not 3. This makes (Type, Num) a safe unique identifier for a key:
{TypeJournal, 2} β 000002.log (WAL)
{TypeManifest, 1} β MANIFEST-000001
{TypeTable, 3} β 000003.ldb (first SSTable)
{TypeTable, 4} β 000004.ldb
β file 3 deleted β next is 5, not 3
{TypeTable, 5} β 000005.ldb
The four file types and their byte values:
| Constant | Value | Purpose |
|---|---|---|
TypeManifest | 0x01 | Tracks which SSTables are live (replaces CURRENT file) |
TypeJournal | 0x02 | Write-Ahead Log β records every write before memtable flush |
TypeTable | 0x04 | SSTable β sorted, immutable on-disk data file |
TypeTemp | 0x08 | Scratch file used during compaction, then renamed |
TypeAll | 0x0F | Bitmask combining all four β used in List() filter |
4.2 Namespace = Your Database Name
The ns prefix you pass to New() is the entire database identity. Two
Storage instances with different namespaces share the same FDB cluster but
never see each otherβs keys:
storA := fdbstorage.New(db, "alice") // all keys start with 0x616c6963 65
storB := fdbstorage.New(db, "bob") // all keys start with 0x626f62
Both write SSTable num=3 (TypeTable=4). Their FDB keys are completely different:
alice's SSTable: 61 6c 69 63 65 01 04 00 00 00 00 00 00 00 03
βββ "alice" βββ
bob's SSTable: 62 6f 62 01 04 00 00 00 00 00 00 00 03
ββ "bob" β
FDB sees them as unrelated keys in a flat sorted list. No schema, no directory, no tenant table β just different byte prefixes.
4.3 Your User Data is NOT an FDB Key
This surprises most people. When you write:
db.Put([]byte("apple"), []byte("red"), nil)
db.Put([]byte("banana"), []byte("yellow"), nil)
db.Put([]byte("cherry"), []byte("red"), nil)
apple, banana, and cherry never appear as FDB keys. LevelDB packs
them together into an SSTable file (a sorted binary format), then our
flush() stores that binary blob across 64KB FDB chunk values:
User writes: apple β red
banana β yellow β LevelDB holds these in memory
cherry β red
β (on db.Close or memtable flush)
LevelDB creates one SSTable file: {TypeTable, Num=3}
β
FDB sees:
"mydb" 01 04 0000000000000003 β size (8 bytes) β file exists
"mydb" 02 04 0000000000000003 00000000 β <SSTable binary blob>
β TypeTable=4 β the blob contains apple+banana+cherry
chunk 0 encoded in LevelDB's internal format
FDB stores opaque bytes. It has no idea whatβs inside the chunk values. The apple/banana/cherry data is invisible to FDB β it can only be decoded by LevelDB when it reads the SSTable back.
4.4 How db.Get("bob") Actually Works β The Two-Level Lookup
If FDB only stores opaque SSTable blobs, how does LevelDB ever find bob?
The answer is a two-level search: LevelDB decides which file, then FDB
delivers that fileβs bytes, then LevelDB searches inside the bytes.
db.Get([]byte("bob"))
β
βΌ
ββββββββββββββββββββββββββββββββββββββββ
β LEVEL 1: LevelDB finds the FILE β β no FDB I/O yet
β βββββββββββββββββββββββββββββββββββββββ£
β 1. Check memtable (unflushed writes)
β 2. Read MANIFEST β live SSTables: #3, #5, #7
β 3. Check each SSTable's Bloom filter
β β "bob is probably in SSTable #5"
β 4. Read SSTable #5's index block
β β "bob lives in the block at byte offset 4096"
ββββββββββββββββββββββββββββββββββββββββ
β
βΌ stor.Open(FileDesc{TypeTable, 5}) β FDB call here
ββββββββββββββββββββββββββββββββββββββββββ
β LEVEL 2: FDB reassembles the file β
β βββββββββββββββββββββββββββββββββββββββββ£
β Range scan: ns 02 04 0000000000000005 *
β chunk 0 β 64 KB of SSTable bytes
β chunk 1 β 64 KB of SSTable bytes
β chunk 2 β remainder
β β reassemble β bytes.Reader
ββββββββββββββββββββββββββββββββββββββββββ
β
βΌ (back in LevelDB, no more FDB I/O)
binary-search SSTable at byte offset 4096
find key "bob" β return value "smith"
What the FDB keys encode is only the file address, never the user key:
ns + 0x02 + 0x04 + 0000000000000005 + 00000000
β β β β ββ chunk index
β β β ββ file number (5)
β β ββ FileType (0x04 = TypeTable)
β ββ tag byte (0x02 = file data)
ββ namespace ("mydb")
bob is packed inside the value bytes of those chunks, in LevelDBβs
own sorted-block binary format, alongside every other key in that SSTable.
The three layers of indexing, summarized:
| Layer | What it indexes | How it searches |
|---|---|---|
| FDB | File #5, chunks 0βN | Key range scan ns 02 04 00000005 * |
| LevelDB | βbob is in file #5β | MANIFEST + Bloom filter + index block |
| SSTable binary | βbob is at block offset 4096β | Binary search on sorted key blocks |
FDB is a file store. LevelDB is a keyβvalue store built on top of it.
The bob β smith lookup is entirely LevelDBβs responsibility. FDB just
delivers SSTable #5βs bytes on demand.
4.5 Many Writes, One Flush β How Records Accumulate
Say you write 22 records:
db.Put([]byte("alex"), []byte("20"), nil)
db.Put([]byte("bob"), []byte("25"), nil)
// ... 20 more
Do each of those create a new FDB key? No. Here is the full path:
db.Put("alex", "20")
db.Put("bob", "25") β both go here immediately:
db.Put(...) β
βββββββββββββββββββββββ
β WAL (TypeJournal) β β append each record as binary
β in-memory buffer β Sync() β written to FDB
βββββββββββββββββββββββ
βββββββββββββββββββββββ
β Memtable β β sorted in-memory skip-list
β alex=20, bob=25... β all 22 records live here
βββββββββββββββββββββββ
β (memtable full, ~4 MB, or db.Close())
βΌ
LevelDB flushes the ENTIRE memtable
as ONE SSTable file (TypeTable, Num=3)
β
βΌ stor.Create({TypeTable,3}) β flush() β FDB
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β FDB keys written (all in one or two transactions): β
β ns 01 04 0000000000000003 β meta (size) β
β ns 02 04 0000000000000003 00000000 β chunk 0: sorted blob β
β β all 22 records (alex, bob, ...) packed in here β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key rules:
| Rule | Why |
|---|---|
| All 22 records go into the same SSTable | One memtable β one flush β one file |
| The SSTable is written once, in full | flush() fires on Close() only |
| The SSTable is immutable forever after | SSTables are never modified |
A new write to "alex" does NOT update SSTable #3 | It goes into the next memtable β SSTable #5 |
Two versions of "alex" can exist simultaneously | LevelDB uses sequence numbers to pick the newest |
What about the WAL?
The WAL (TypeJournal) is different β it IS append-like. As you call
db.Put(), LevelDB appends binary records to its Journal writer. In our
FDB implementation, every Sync() call rewrites the Journalβs chunks from
scratch (chunk 0, 1, 2β¦) because our writer accumulates into a
bytes.Buffer and flush() always writes the full buffer. There is no
partial append in FDB β we over-write all chunks with the latest snapshot
of the buffer.
after db.Put("alex"): WAL buffer = [record-for-alex]
Sync() β FDB chunk 0 = [record-for-alex] (32 bytes)
after db.Put("bob"): WAL buffer = [record-for-alex | record-for-bob]
Sync() β FDB chunk 0 = [record-for-alex | record-for-bob]
(entire buffer, overwriting previous chunk 0)
Once the memtable is flushed to an SSTable, the WAL is deleted (itβs no longer needed for recovery β the data is in the immutable SSTable).
Each LevelDB file is identified by (FileType, FileNum). We encode this as:
Meta key (file existence + type):
ns + tagFileMeta(0x01) + fileType(1 byte) + fileNum(8 bytes BE)
β msgpack({Type: ft, Num: n, size: totalBytes})
Data chunks:
ns + tagFileData(0x02) + fileType(1 byte) + fileNum(8 bytes BE) + chunkNum(8 bytes BE)
β up to 64 KiB of file data
Manifest pointer (replaces CURRENT file):
ns + tagManifest(0x03)
β msgpack(FileDesc{Type: TypeManifest, Num: n})
Lock key:
ns + tagLock(0x04)
β "locked" (any non-empty value means locked)
Why big-endian for file numbers?
Big-endian encoding preserves sort order. File numbers are monotonically
increasing (LevelDB never reuses a file number). By storing them big-endian,
a range scan over ns+tagFileData+ft+num+* returns chunks in chunk-number
order β which is the correct order to reassemble the file. Without big-endian
encoding, chunk 10 would sort before chunk 2 (0x0A < 0x02 is false in
big-endian but 0x0000000A < 0x00000002 is also false β you need
lexicographic order over big-endian bytes).
File number and type as part of the key:
This means all chunks of file (TypeJournal, 3) sort together, before all
chunks of (TypeJournal, 4), which sort before (TypeTable, 5). Clean,
hierarchical key organization.
5. Key Layout Deep Dive: From Function Call to FDB Bytes
This section traces the complete journey of every operation β from a Go
function call in storage.go down to the raw bytes written in FDB. If you
read only one section, read this one.
5.1 The Four Tag Bytes
const (
tagFileMeta byte = 0x01 // "does this file exist, and how big is it?"
tagFileData byte = 0x02 // "here are the actual file contents"
tagManifest byte = 0x03 // "which MANIFEST file is currently active?"
tagLock byte = 0x04 // "is this database open by a process?"
)
FDB is a flat keyβvalue store. There are no tables, folders, or schemas β just a sorted sequence of byte keys. To store four conceptually different things (file metadata, file data chunks, the manifest pointer, the lock) without collisions, the very first byte after the namespace prefix tells you what kind of record you are looking at. This is the tag byte.
Think of it like a URL path prefix:
/meta/...β tag0x01/data/...β tag0x02/manifestβ tag0x03/lockβ tag0x04
5.2 Anatomy of Every Key Type
ns = "mydb" (4 bytes: 0x6D 0x79 0x64 0x62)
ββ tagFileMeta (0x01) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key: 6D 79 64 62 01 04 00 00 00 00 00 00 00 03
βββ ns ββββ β β βββββββ num (int64 BE) βββ
β βββ fd.Type = TypeTable (4 = 0x04)
βββ tagFileMeta
Value: 00 00 00 00 00 20 00 00 (uint64 BE = 2097152 = 2 MB)
ββ tagFileData (0x02) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key: 6D 79 64 62 02 04 00 00 00 00 00 00 00 03 00 00 00 01
βββ ns ββββ β β βββββββ num (int64 BE) βββ βchunkβ
β βββ fd.Type = TypeTable (4)
βββ tagFileData
Value: <65536 bytes of SSTable data, chunk index 1>
ββ tagManifest (0x03) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key: 6D 79 64 62 03
βββ ns ββββ βββ tagManifest (no other fields β there is only one)
Value: 01 00 00 00 00 00 00 00 01
β βββββββ Num = 1 (int64 BE) βββββββββββββββββββββββββββββββββββ
βββ fd.Type = TypeManifest (1 = 0x01)
ββ tagLock (0x04) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key: 6D 79 64 62 04
βββ ns ββββ βββ tagLock
Value: (empty β presence of the key = locked; absent = unlocked)
5.3 Journey: db.Put("hello", "world") β FDB keys
Here is the full call chain from a single LevelDB write to the bytes that land in FDB.
User code
db.Put([]byte("hello"), []byte("world"), nil)
β
βΌ goleveldb internal
memTable.Put(...) β stored in memory only
β
βΌ (on MemTable flush or db.Close)
compaction goroutine
β calls our storage interface:
ββ stor.Create(FileDesc{Type: TypeJournal, Num: 2})
β ββ returns &writer{fd: {TypeJournal, 2}}
β
ββ writer.Write(journalRecord) β buffered in writer.buf
ββ writer.Sync() β calls flush()
β ββ fdb.Transact:
β tr.ClearRange(dataRange({TypeJournal, 2})) β wipe old
β tr.Set(metaKey({TypeJournal, 2}), size_8bytes) β tag 0x01
β tr.Set(chunkKey({TypeJournal, 2}, 0), chunk0) β tag 0x02
β
ββ writer.Close()
At this point two FDB keys exist:
"mydb" 0x01 0x02 0x00000000_00000002 β size (8 bytes)
"mydb" 0x02 0x02 0x00000000_00000002 0x00000000 β journal bytes
(0x02 in the 3rd byte position is TypeJournal = 2, not tagFileData.)
5.4 Journey: stor.Open(FileDesc{TypeJournal, 2})
When goleveldb recovers after a restart it calls Open on each journal file
it found via List. Here is what happens:
// In storage.go β Open()
func (s *Storage) Open(fd storage.FileDesc) (storage.Reader, error) {
v, err := s.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
// Step 1 β fetch the meta key (tag 0x01)
// Key: "mydb" 0x01 0x02 0x00000000_00000002
meta, err := rt.Get(s.metaKey(fd)).Get()
// meta == []byte{0x00,0x00,0x00,0x00,0x00,0x00,0x04,0x00} (1024 bytes)
size := binary.BigEndian.Uint64(meta) // = 1024
// Step 2 β range-scan all chunk keys (tag 0x02)
// Range: ["mydb" 0x02 0x02 0x00000000_00000002]
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// ["mydb" 0x02 0x02 0x00000000_00000002 0xFF 0xFF 0xFF 0xFF 0xFF]
kvs, _ := rt.GetRange(s.dataRange(fd), fdb.RangeOptions{}).GetSliceWithError()
// kvs[0].Key = "mydb" 0x02 0x02 ... 0x00000000 (chunk 0)
// kvs[0].Value = <1024 bytes>
buf := make([]byte, 0, size)
for _, kv := range kvs {
buf = append(buf, kv.Value...) // reassemble from chunks in order
}
return buf, nil
})
return &reader{bytes.NewReader(v.([]byte))}, nil
}
Two FDB reads, one round-trip (both happen inside one ReadTransact).
5.5 Journey: stor.List(TypeAll)
Called at startup. goleveldb needs to know every file that exists so it can decide which journal files to replay and which SSTables are live.
// In storage.go β List()
func (s *Storage) List(ft storage.FileType) ([]storage.FileDesc, error) {
// Scan ONLY the tag-0x01 band β never touch the data keys.
allMeta := fdb.KeyRange{
Begin: fdb.Key(append([]byte(s.ns), tagFileMeta)), // "mydb" 0x01
End: fdb.Key(append([]byte(s.ns), tagFileMeta+1)), // "mydb" 0x02
}
// Returns all keys that start with "mydb" 0x01 ...
// Example keys returned:
// "mydb" 0x01 0x01 0x00000000_00000001 β MANIFEST-000001
// "mydb" 0x01 0x02 0x00000000_00000002 β 000002.log
// "mydb" 0x01 0x04 0x00000000_00000003 β 000003.ldb
// "mydb" 0x01 0x04 0x00000000_00000004 β 000004.ldb
v, _ := s.fdb.ReadTransact(...)
prefixLen := len(s.ns) + 2 // skip: ns + tagFileMeta + ftype byte
for _, kv := range v.([]fdb.KeyValue) {
k := []byte(kv.Key)
thisFT := storage.FileType(k[len(s.ns)+1]) // byte after the tag
if ft&thisFT == 0 { continue } // bitmask filter
num := int64(binary.BigEndian.Uint64(k[prefixLen : prefixLen+8]))
out = append(out, storage.FileDesc{Type: thisFT, Num: num})
}
}
One range scan, one round-trip. Notice that the data chunks (tag 0x02) are
never touched β List only reads the tiny meta keys (tag 0x01). This is why
having separate tags for metadata vs data matters: you can enumerate all files
without reading any file contents.
5.6 Journey: stor.SetMeta(FileDesc{TypeManifest, 1})
Called by goleveldb after it writes a new MANIFEST file, to record βthis is now the current MANIFESTβ.
func (s *Storage) SetMeta(fd storage.FileDesc) error {
_, err := s.fdb.Transact(func(tr fdb.Transaction) (interface{}, error) {
// encodeFD packs 9 bytes: 1 byte type + 8 bytes num BE
// fd = {TypeManifest=1, Num=1}
// encoded = [0x01, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01]
tr.Set(s.manifestKey(), encodeFD(fd))
// βββ key: "mydb" 0x03 (just the ns + tag, no other fields)
return nil, nil
})
return err
}
And the reverse, GetMeta():
func (s *Storage) GetMeta() (storage.FileDesc, error) {
v, _ := s.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.Get(s.manifestKey()).Get() // key: "mydb" 0x03
})
b := v.([]byte)
if b == nil {
return storage.FileDesc{}, os.ErrNotExist // fresh DB β no manifest yet
}
// b = [0x01, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01]
return decodeFD(b) // β {TypeManifest, Num: 1}
}
5.7 What the FDB keyspace looks like for a small database
After running demo/main.go and writing a few keys, the entire FDB namespace
"mydb" might look like this (printed as hexdump | ascii):
KEY VALUE
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
mydbΒ·03 \x01Β·\x00\x00\x00\x00\x00\x00\x00\x01
β MANIFEST pointer β {TypeManifest, 1}
mydbΒ·01Β·01Β·\x00\x00\x00\x00\x00\x00\x00\x01 \x00\x00\x00\x00\x00\x00\x04\xA0
β meta for MANIFEST-000001, size=1184
mydbΒ·02Β·01Β·\x00\x00\x00\x00\x00\x00\x00\x01Β·\x00\x00\x00\x00 <1184 bytes>
β MANIFEST-000001 data, chunk 0
mydbΒ·01Β·02Β·\x00\x00\x00\x00\x00\x00\x00\x02 \x00\x00\x00\x00\x00\x00\x10\x00
β meta for 000002.log, size=4096
mydbΒ·02Β·02Β·\x00\x00\x00\x00\x00\x00\x00\x02Β·\x00\x00\x00\x00 <4096 bytes>
β 000002.log data, chunk 0
mydbΒ·01Β·04Β·\x00\x00\x00\x00\x00\x00\x00\x03 \x00\x00\x00\x00\x00\x20\x00\x00
β meta for 000003.ldb, size=2MB
mydbΒ·02Β·04Β·\x00\x00\x00\x00\x00\x00\x00\x03Β·\x00\x00\x00\x00 <65536 bytes>
mydbΒ·02Β·04Β·\x00\x00\x00\x00\x00\x00\x00\x03Β·\x00\x00\x00\x01 <65536 bytes>
... β 000003.ldb data, 32 chunks
mydbΒ·02Β·04Β·\x00\x00\x00\x00\x00\x00\x00\x03Β·\x00\x00\x00\x1F <last chunk>
Key observations:
- All
0x01(meta) keys sort before all0x02(data) keys β because0x01 < 0x02. - Within the
0x01band, TypeManifest (0x01) sorts before TypeJournal (0x02) sorts before TypeTable (0x04) β because their type bytes are in numeric order. - Chunks within a file sort by chunk index β because BE integers preserve numeric order.
List()range-scans only[mydbΒ·01, mydbΒ·02)β skipping all chunk data entirely.
5.8 Verifying with a test
Here is a self-contained Go test that exercises every code path above and lets you inspect the raw FDB keys to verify they match the byte layout:
// fdbstorage/storage_layout_test.go
package fdbstorage_test
import (
"bytes"
"encoding/hex"
"fmt"
"testing"
"github.com/apple/foundationdb/bindings/go/src/fdb"
"github.com/syndtr/goleveldb/leveldb/storage"
fdbstorage "github.com/your-module/fdbstorage"
)
func TestKeyLayout(t *testing.T) {
fdb.MustAPIVersion(620)
db := fdb.MustOpenDefault()
stor := fdbstorage.New(db, "testlayout")
stor.Wipe() // always start from a clean slate
// ββ 1. Write a small file ββββββββββββββββββββββββββββββββββββββββββββ
fd := storage.FileDesc{Type: storage.TypeJournal, Num: 7}
w, err := stor.Create(fd)
if err != nil {
t.Fatal(err)
}
payload := []byte("hello from the journal")
w.Write(payload)
if err := w.Close(); err != nil {
t.Fatal(err)
}
// ββ 2. SetMeta (manifest pointer) βββββββββββββββββββββββββββββββββββ
manifest := storage.FileDesc{Type: storage.TypeManifest, Num: 1}
if err := stor.SetMeta(manifest); err != nil {
t.Fatal(err)
}
// ββ 3. Dump all raw FDB keys in the namespace ββββββββββββββββββββββββ
// Expected output:
// 74657374 6c61796f 7574 03 β manifest key
// 74657374 6c61796f 7574 01 02 0000000000000007 β meta key
// 74657374 6c61796f 7574 02 02 0000000000000007 00000000 β data chunk 0
allKeys, err := stor.DumpKeys()
if err != nil {
t.Fatal(err)
}
fmt.Println("\n=== raw FDB keys in namespace ===")
for _, k := range allKeys {
fmt.Printf(" %s\n", hex.EncodeToString(k))
}
if len(allKeys) != 3 {
t.Fatalf("want 3 keys (manifest + meta + 1 chunk), got %d", len(allKeys))
}
// ββ 4. List round-trip βββββββββββββββββββββββββββββββββββββββββββββββ
fds, err := stor.List(storage.TypeAll)
if err != nil {
t.Fatal(err)
}
if len(fds) != 1 || fds[0] != fd {
t.Fatalf("List: want [%v], got %v", fd, fds)
}
// ββ 5. Open round-trip (bytes must match) ββββββββββββββββββββββββββββ
r, err := stor.Open(fd)
if err != nil {
t.Fatal(err)
}
buf := make([]byte, len(payload))
if _, err := r.Read(buf); err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf, payload) {
t.Fatalf("Open: want %q, got %q", payload, buf)
}
// ββ 6. GetMeta round-trip ββββββββββββββββββββββββββββββββββββββββββββ
got, err := stor.GetMeta()
if err != nil {
t.Fatal(err)
}
if got != manifest {
t.Fatalf("GetMeta: want %v, got %v", manifest, got)
}
// ββ 7. Remove clears both meta and data keys βββββββββββββββββββββββββ
if err := stor.Remove(fd); err != nil {
t.Fatal(err)
}
remaining, _ := stor.DumpKeys()
// Only the manifest key should remain
if len(remaining) != 1 {
t.Fatalf("after Remove: want 1 key, got %d: %v", len(remaining),
func() []string {
var s []string
for _, k := range remaining {
s = append(s, hex.EncodeToString(k))
}
return s
}())
}
stor.Wipe()
}
To use DumpKeys you need to add this helper to fdbstorage/storage.go:
// DumpKeys returns all raw FDB keys in this storage's namespace, in order.
// Intended for tests and debugging only.
func (s *Storage) DumpKeys() ([][]byte, error) {
nsEnd := append(append([]byte{}, s.ns...), 0xff)
v, err := s.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(fdb.KeyRange{
Begin: fdb.Key(s.ns),
End: fdb.Key(nsEnd),
}, fdb.RangeOptions{}).GetSliceWithError()
})
if err != nil {
return nil, err
}
kvs := v.([]fdb.KeyValue)
out := make([][]byte, len(kvs))
for i, kv := range kvs {
out[i] = []byte(kv.Key)
}
return out, nil
}
The test confirms:
Create+Closeproduces exactly one meta key (0x01) and one chunk key (0x02).Listfinds the file by scanning only0x01keys.Openreconstructs the payload exactly by reading the0x02chunk.SetMeta/GetMetaround-trips through the single0x03key.Removeclears both the0x01meta key and all0x02chunk keys, leaving nothing behind.
5.9 Live Simulation β Annotated Output
The demo/simulate.go program writes three files, dumps every raw FDB key, reads
them back, and calls GetMeta + List. Run it with:
go run ./demo/ -sim
Here is the full output with every byte explained.
Phase 1 β Write
wrote MANIFEST-000001 "MANIFEST-content"
wrote 000002.log "WAL-entry-bytes"
wrote 000005.ldb "SST-block-bytes"
Each call is:
stor.Create(fd) // returns a *writer with an empty bytes.Buffer β no FDB I/O yet
w.Write([]byte(...)) // appends into writer.buf β still no FDB I/O
w.Close() // calls flush() β fires fdb.Transact
// tr.Set(metaKey(fd), sizeBytes) β tag 0x01 key
// tr.Set(chunkKey(fd,0), data) β tag 0x02 key
Nothing reaches FDB until Close() (or Sync()). LevelDB thinks it has a
real file. FDB only learns about it at flush time.
Phase 2 β FDB Keyspace Dump
META key=73696d0001010000000000000001 β type=Manifest num=1 size=16 B
META key=73696d0001020000000000000002 β type=Journal num=2 size=15 B
META key=73696d0001040000000000000005 β type=Table num=5 size=15 B
Byte-by-byte for the first META key:
73 69 6d 00 β namespace "sim\x00" (4 bytes)
01 β tagFileMeta (the "directory entry" tag)
01 β fd.Type = TypeManifest = 1
00 00 00 00 00 00 00 01
βββββββ fd.Num = 1, big-endian int64 ββββ
Value: 00 00 00 00 00 00 00 10 β uint64 BE = 16 β file size in bytes
The three CHUNK keys follow immediately after in the sorted order:
CHUNK key=73696d000201000000000000000100000000 β type=Manifest chunk=0 16 B
73 69 6d 00 β namespace
02 β tagFileData (actual content)
01 β TypeManifest
00 00 00 00 00 00 00 01 β num=1
00 00 00 00 β chunk index 0 (uint32 BE)
Value: "MANIFEST-content" (16 bytes, the raw file payload)
The sorted order in FDB is:
All META (0x01) keys come first because 0x01 < 0x02
βββ TypeManifest (0x01) meta β 0x01Β·0x01Β·...
βββ TypeJournal (0x02) meta β 0x01Β·0x02Β·...
βββ TypeTable (0x04) meta β 0x01Β·0x04Β·...
All CHUNK (0x02) keys come next
βββ TypeManifest (0x01) chunk 0 β 0x02Β·0x01Β·...Β·0x00000000
βββ TypeJournal (0x02) chunk 0 β 0x02Β·0x02Β·...Β·0x00000000
βββ TypeTable (0x04) chunk 0 β 0x02Β·0x04Β·...Β·0x00000000
MANIF (0x03) key β single key, no extra fields
The MANIF key:
MANIF key=73696d0003 β points to type=Manifest num=1
73 69 6d 00 β namespace
03 β tagManifest (that's it β only one manifest at a time)
Value: 01 00 00 00 00 00 00 00 01
β βββββββ Num = 1 ββββββ
βββ fd.Type = TypeManifest = 1
This is exactly what encodeFD produces and decodeFD expects.
Phase 3 β Read Back
open MANIFEST-000001 β "MANIFEST-content"
open 000002.log β "WAL-entry-bytes"
open 000005.ldb β "SST-block-bytes"
Each Open(fd) fires one FDB ReadTransact with two operations:
1. rt.Get(metaKey(fd)) β fetch the 0x01 key β confirms file exists + size
2. rt.GetRange(dataRange(fd)) β fetch all 0x02 keys β reassemble bytes in chunk order
Both happen inside the same snapshot. FDB guarantees you see a consistent view: no partial writes, no torn reads across chunks.
Phase 4 β GetMeta + List
GetMeta() β MANIFEST-000001
List(TypeAll):
MANIFEST-000001
000002.log
000005.ldb
GetMeta() is one point-read on the single 0x03 key.
List(TypeAll) is one range scan over [nsΒ·0x01, nsΒ·0x02) β the entire
META band. It decodes the type byte and num from each key, never touching
a single CHUNK key:
thisFT := storage.FileType(k[len(s.ns)+1]) // byte at position: ns + tagByte + HERE
num := int64(binary.BigEndian.Uint64(k[prefixLen : prefixLen+8]))
Three files β three META keys scanned β three FileDesc returned. The nine
CHUNK keys (one per file) are not even requested.
Summary table
| Key hex prefix | Tag | What it represents | Read by |
|---|---|---|---|
73696d00Β·01Β·01Β·β¦ | 0x01 | MANIFEST file exists, size=N | Open, List |
73696d00Β·01Β·02Β·β¦ | 0x01 | Journal file exists, size=N | Open, List |
73696d00Β·01Β·04Β·β¦ | 0x01 | Table file exists, size=N | Open, List |
73696d00Β·02Β·01Β·β¦Β·chunk | 0x02 | MANIFEST file content | Open only |
73696d00Β·02Β·02Β·β¦Β·chunk | 0x02 | Journal file content | Open only |
73696d00Β·02Β·04Β·β¦Β·chunk | 0x02 | Table file content | Open only |
73696d00Β·03 | 0x03 | Current MANIFEST pointer | GetMeta only |
6. Chunking: Overcoming the 100 KiB Value Limit
FDB has a hard limit: values may not exceed 100 KiB (102,400 bytes). A typical LevelDB SSTable is 2β4 MB. We cannot store it in one FDB value.
Our solution: split each file into 64 KiB chunks:
const chunkSize = 64 * 1024 // 65,536 bytes
// Writing a 200 KiB file:
// Chunk 0: bytes [0, 65536)
// Chunk 1: bytes [65536, 131072)
// Chunk 2: bytes [131072, 200000) (partial last chunk)
Each chunk is stored as a separate FDB key-value pair:
ns+0x02+ft+num+00000000_00000000 β 65536 bytes
ns+0x02+ft+num+00000000_00000001 β 65536 bytes
ns+0x02+ft+num+00000000_00000002 β 68528 bytes (partial)
Reading a file: range-scan all chunk keys for (ft, num), sort by chunk
number (already in order due to big-endian encoding), concatenate the values.
func (s *Storage) Open(fd storage.FileDesc) (storage.Reader, error) {
kvs, _ := s.db.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(s.dataRange(fd), fdb.RangeOptions{}).GetSliceWithError()
})
var buf []byte
for _, kv := range kvs.([]fdb.KeyValue) {
buf = append(buf, kv.Value...)
}
return io.NopCloser(bytes.NewReader(buf)), nil
}
Why 64 KiB chunks?
- Smaller than FDBβs 100 KiB value limit β
- Large enough to minimize key overhead (a 4 MB SSTable = 64 chunks, not thousands)
- Aligns with filesystem block sizes (4β64 KiB typical)
7. Atomic Rename β Durabilityβs Secret Weapon
POSIX rename(src, dst) is the single most important durability primitive in
filesystems. Its contract: after rename returns, dst exists and
src does not, with no window where neither exists. This is atomic
replacement.
LevelDB uses rename heavily:
Rename(TypeTemp, n, TypeTable, n): promote temp SSTable to final nameRename(TypeTemp, n, TypeManifest, n): promote temp manifest
Without atomicity, a crash during rename could leave:
- Neither file existing β data loss
- Both files existing β ambiguity about which is current
- A partial file at dst β corruption
In FDB, we implement atomic rename as copy + clear in one transaction:
func (s *Storage) Rename(oldfd, newfd storage.FileDesc) error {
_, err := s.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
// 1. Read all old chunks
kvs, _ := tr.GetRange(s.dataRange(oldfd), fdb.RangeOptions{}).GetSliceWithError()
// 2. Clear old data
tr.ClearRange(s.dataRange(oldfd))
tr.Clear(s.metaKey(oldfd))
// 3. Write new data
for _, kv := range kvs {
newKey := s.translateChunkKey(kv.Key, oldfd, newfd)
tr.Set(newKey, kv.Value)
}
tr.Set(s.metaKey(newfd), metaBytes)
return nil, nil
})
return err
}
One transaction. The cluster either commits all of this (old is gone, new is present) or none of it (crash safety). The atomicity guarantee is identical to POSIX rename β and arguably stronger, since FDB replicates the commit across multiple machines before returning.
The 10 MB transaction limit:
FDB transactions are limited to ~10 MB of reads + writes. A large SSTable (4 MB) would have chunks adding up to 4 MB of writes in one transaction. Thatβs under the 10 MB limit. But 8 MB SSTables would be risky.
Our Rename reads all chunks in the transaction (4 MB reads) and writes them
all back (4 MB writes) β totaling 8 MB. Safe for typical LevelDB files.
For larger files, weβd need to either:
- Break the rename into multiple transactions (violating atomicity), or
- Use a two-phase approach: write new chunks in a first transaction, then atomically swap the meta key in a second transaction (using a PENDING state key as the βin-progress renameβ marker).
8. The Writer: Batching Chunks into Transactions
When LevelDB writes a new SSTable, it calls Create(fd) which returns a
Writer. The writer accumulates bytes via Write(p []byte). When Close()
is called, we flush everything to FDB.
Batch size:
const maxChunksPerTx = 100 // 100 Γ 64 KiB = 6.4 MB per transaction
We flush up to 100 chunks per FDB transaction. This stays well within the 10 MB limit. A 20 MB SSTable would be flushed in 4 transactions of 5 MB each.
func (w *writer) flush(final bool) error {
start := w.flushedChunks
end := start + maxChunksPerTx
if end > len(w.chunks) {
end = len(w.chunks)
}
_, err := w.s.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
for i := start; i < end; i++ {
tr.Set(w.s.chunkKey(w.fd, i), w.chunks[i])
}
if final && end == len(w.chunks) {
// Write the meta key only on the final flush
tr.Set(w.s.metaKey(w.fd), metaBytes)
}
return nil, nil
})
w.flushedChunks = end
return err
}
The meta-key-last invariant:
We write the meta key (the fileβs βdirectory entryβ) only in the last batch
of chunks. This ensures that List() never returns a file whose chunks are
only partially written β the file is only βvisibleβ once all its chunks exist.
This is the FDB equivalent of:
- Write all content to a temp file
rename(temp, final)atomically
9. Why the WAL is Redundant With FDB
LevelDBβs Write-Ahead Log (WAL / journal, TypeJournal) exists for one
reason: crash recovery. If the process crashes after writing to the in-memory
MemTable but before flushing the MemTable to an SSTable on disk, the WAL
is replayed to reconstruct the MemTable.
With FDB as the storage backend:
Every write is already durable before Write(p) returns.
Our writer.Write buffers bytes in memory. Our writer.Close flushes to FDB
in transactions. Each FDB Transact call does not return until the commit is
confirmed by FDBβs replication protocol β the data is on at least f+1
machines (where f is the fault tolerance level, typically 2). A process
crash after Close() returns means the data is safe.
The WAL is protecting against βdata written to OS memory but not yet on disk.β
FDBβs Transact eliminates this window. By the time the WAL file is written
through our fdbstorage.Writer, the bytes are already in FDB.
A production implementation would patch goleveldb to skip WAL writes
entirely (or use Options.DisableSeeksCompaction and a custom journal
implementation thatβs a no-op). This would improve write throughput by 50% or
more and reduce FDB key usage.
10. The Blob Layer Pattern
FDBβs core team documented the βBlob Layerβ pattern: storing binary blobs (arbitrary large byte arrays) in FDB by chunking them. Our file storage is an instance of this pattern.
The Blob Layer pattern:
blob_key + chunkNum β chunk_data
It solves the 100 KiB value limit while preserving atomic operations on the whole blob (via FDB transactions) and efficient byte-range access (read only the chunks you need, e.g., for seeking within a large file).
Applications:
- Store large media files (> 100 KiB) in FDB for atomic metadata-plus-content updates
- Store ML model weights alongside their metadata records
- Store serialized protocol buffers larger than 100 KiB
- Back any file system abstraction (exactly what weβre doing)
11. Real-World Analogues
RocksDB Remote Compaction (Project Titan, Ripple)
Meta (Facebook) runs RocksDB on distributed storage in some configurations. Their βRippleβ project stores RocksDB SSTables in a distributed block store (similar to HDFS or GFS). The storage interface they use is exactly the same concept: RocksDB writes βfilesβ via an abstract interface; the implementation stores chunks in a distributed system.
TiKV on Disaggregated Storage
TiDB (PingCAP) is moving toward a disaggregated architecture where TiKV (which uses RocksDB internally) stores its SSTables in object storage (S3, GCS). The TiKV storage engine writes SSTables through an abstract file interface to S3. This is identical to our pattern.
Pebble (CockroachDB)
CockroachDB replaced RocksDB with Pebble (a Go implementation) in 2021.
Pebble has a vfs.FS interface β a virtual filesystem abstraction β that
allows swapping the storage backend. CockroachDB uses this for testing (an
in-memory FS) and is exploring using it for cloud storage.
The Patternβs Universality
Every LSM-tree engine eventually adds a pluggable storage interface:
- LevelDB:
storage.Storage - RocksDB:
Env(virtual filesystem) - Pebble:
vfs.FS - WiredTiger:
WT_FILE_SYSTEM
Why? Because running the compaction engine without worrying about where data lives is architecturally clean. The engine is responsible for LSM semantics; the storage interface is responsible for durability. Separation of concerns.
12. Exercises
Exercise 1 β Streaming Reader
Instead of materializing the entire file into memory in Open(), return an
io.ReadSeekCloser that fetches chunks lazily. A read at offset 128 KiB should
only fetch chunks 2β3, not chunk 0 and 1.
This reduces memory usage for large SSTables and enables efficient
Seek(offset, io.SeekStart) for random-access reads.
Exercise 2 β File Size Cache
List() currently returns all file descriptors by scanning the meta keys.
Open(fd) reads the meta key to get the file size, then reads all chunk keys.
Add a small in-memory LRU cache mapping FileDesc β size. On Open, check
the cache first. Invalidate the cache entry on Remove and Rename.
Measure the reduction in FDB round-trips for a workload with many small reads on recently-opened files.
Exercise 3 β Compression
Before storing each 64 KiB chunk, compress it with compress/flate or
github.com/golang/snappy. Store a compression-type byte in the meta key.
On read, decompress transparently.
LevelDB SSTables are already internally compressed (Snappy by default), so this may not reduce size much for TypeTable files. But TypeJournal files are not compressed and might benefit.
Exercise 4 β Two-Phase Large Rename
For files larger than 5 MB (which would exceed the transaction limit in
our current Rename), implement the two-phase rename:
Phase 1: Write all new chunks in multiple transactions. Write a
βrename-pendingβ key: ns+tagPending+oldfd β newfd.
Phase 2: In one transaction, atomically: clear the pending key, clear all old chunks and meta, set the meta for new fd (chunks already exist).
On startup, check for any pending keys and complete or roll back the rename. This is essentially a two-phase commit for large file renames.
Exercise 5 β Multi-Tenant Databases
Add a namespace concept: allow multiple LevelDB databases to share one FDB
cluster with independent key spaces. Each New(fdb, namespace) call returns
a storage implementation that is completely isolated from others.
This is how mvsqlite handles multiple SQLite βdatabase filesβ β each is an FDB namespace.
13. Source Code Deep Dive β fdbstorage/storage.go
The Storage Struct
type Storage struct {
db fdb.Database
ns []byte
}
Minimal. db is the FDB connection; ns is the byte prefix for all keys. The entire storage is two fields. All complexity lives in the key encoding and transaction logic.
Key Encoding Helpers
func (s *Storage) metaKey(fd storage.FileDesc) fdb.Key {
// ns + 0x01 + type(1 byte) + num(8 bytes big-endian)
key := make([]byte, len(s.ns)+10)
copy(key, s.ns)
key[len(s.ns)] = 0x01
key[len(s.ns)+1] = byte(fd.Type)
binary.BigEndian.PutUint64(key[len(s.ns)+2:], uint64(fd.Num))
return fdb.Key(key)
}
func (s *Storage) chunkKey(fd storage.FileDesc, chunkNum int) fdb.Key {
// ns + 0x02 + type(1 byte) + num(8 bytes) + chunk(8 bytes)
key := make([]byte, len(s.ns)+18)
copy(key, s.ns)
key[len(s.ns)] = 0x02
key[len(s.ns)+1] = byte(fd.Type)
binary.BigEndian.PutUint64(key[len(s.ns)+2:], uint64(fd.Num))
binary.BigEndian.PutUint64(key[len(s.ns)+10:], uint64(chunkNum))
return fdb.Key(key)
}
Why 8-byte big-endian for chunkNum? Chunk numbers are read back via GetRange which returns chunks in key order. Big-endian ensures key order equals chunk number order. If we used little-endian, chunk 256 (LE: 00 01 00 00 00 00 00 00) would sort before chunk 1 (LE: 01 00 00 00 00 00 00 00) β wrong.
The dataRange Helper
func (s *Storage) dataRange(fd storage.FileDesc) fdb.KeyRange {
begin := s.chunkKey(fd, 0)
// end: same prefix but with chunkNum = MaxUint64 + 1 β use next-prefix trick
endPrefix := make([]byte, len(s.ns)+10) // ns + 0x02 + type + num
copy(endPrefix, s.ns)
endPrefix[len(s.ns)] = 0x02
endPrefix[len(s.ns)+1] = byte(fd.Type)
binary.BigEndian.PutUint64(endPrefix[len(s.ns)+2:], uint64(fd.Num))
end := append(endPrefix, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF)
return fdb.KeyRange{Begin: begin, End: fdb.Key(append(end, 0x01))}
}
This range covers all chunk keys for (type, num) regardless of chunkNum. GetRange(dataRange(fd)) fetches all chunks in order.
The List() Implementation
func (s *Storage) List() ([]storage.FileDesc, error) {
kvs, _ := s.db.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(s.metaRange(), fdb.RangeOptions{}).GetSliceWithError()
})
var fds []storage.FileDesc
for _, kv := range kvs.([]fdb.KeyValue) {
var fd storage.FileDesc
msgpack.Unmarshal(kv.Value, &fd)
fds = append(fds, fd)
}
return fds, nil
}
A single range scan over all meta keys returns all files in one round-trip. LevelDB calls List() at startup to find all existing files. With FDB, this is O(1) round-trips regardless of file count.
With a local filesystem, List() is an opendir/readdir syscall β also O(1) in latency, but I/O must go through the local disk controller. With FDB, the I/O goes to the closest FDB storage server over the network, with similar or lower latency than a rotational disk.
The Lock Implementation
func (s *Storage) Lock() (util.Releaser, error) {
_, err := s.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
existing, _ := tr.Get(s.lockKey()).Get()
if len(existing) > 0 {
return nil, errors.New("storage: already locked")
}
tr.Set(s.lockKey(), []byte("locked"))
return nil, nil
})
if err != nil {
return nil, err
}
return util.ReleaserFunc(func() {
s.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
tr.Clear(s.lockKey())
return nil, nil
})
}), nil
}
The lock is a FDB key. Acquiring the lock: check if the key exists; if not, set it β in one atomic transaction. This check-then-set is race-free because FDBβs optimistic concurrency ensures that if two processes both read βno lockβ and both try to write βlockedβ, only one will commit (the other will conflict and retry, then find the lock held).
Limitation: This is a process-level lock, not a durable lease. If the lock-holding process crashes without calling Release(), the lock remains set until manually cleared. For production, use a lock with an expiry: store the lock as {holder: processID, expires: time.Now().Add(30*time.Second)} and have each lock holder refresh it periodically. A lock that isnβt refreshed is treated as expired.
14. Production Considerations
14.1 Transaction Size for Large SSTables
LevelDB level-0 SSTables are 2β4 MB. Level-1 SSTables are larger (up to L1_target_size, configurable). For a L1_target_size of 64 MB, level-1 SSTables are 64 MB each. Our current Rename would fail for files this large (exceeds the 10 MB transaction limit).
Solution: For production, configure LevelDBβs CompactionTableSize to keep SSTables small:
opts := &opt.Options{
CompactionTableSize: 2 * 1024 * 1024, // 2 MB SSTables
}
2 MB SSTables = 32 chunks of 64 KiB. Rename transaction: 32 reads + 32 writes = 4 MB total. Well within limits.
14.2 Read Performance for Large SSTables
Reading a 2 MB SSTable requires fetching 32 chunks from FDB. Our current implementation reads them in one GetRange β one round-trip, 32 key-value pairs returned. Latency: ~1β5 ms (FDB cluster local read latency).
A local filesystem read of 2 MB: ~1β3 ms on SSD, ~10β20 ms on HDD.
For a warm FDB cluster, FDB storage is competitive with SSDs and dramatically better than spinning disks. For random chunk access (seeking within large files), FDB may be faster because it can pipeline multiple point reads, while a spinning disk requires physical seeking.
14.3 Write Amplification
Our chunking adds write amplification: writing a 64 KiB chunk requires writing the chunk key (18 bytes) + value (64 KiB) = 64 KiB + 18 bytes. The key overhead is <0.03%, negligible.
But FDB itself adds write amplification internally: each committed transaction is written to the Transaction Log (TLog), then asynchronously applied to Storage Servers. The TLog write is sequential (fast). The Storage Server write is to FDBβs B-tree (with its own write amplification). FDBβs overall write amplification is roughly 3β5x β comparable to RocksDBβs LSM write amplification.
14.4 Monitoring
Key metrics for a fdbstorage-backed LevelDB deployment:
- FDB transaction latency P99: should be < 10ms for small transactions (meta reads)
- FDB range scan bytes/second: correlates with compaction throughput
- FDB conflict rate: if high, indicates concurrent compaction and write contention
- LevelDB metrics via
db.GetProperty("leveldb.stats"): still valid β LevelDB reports its own view of compaction and SSTable counts, just the βdisk I/Oβ is actually FDB I/O
15. Interview Questions β Storage Abstractions and LSM Trees
Q: What is the purpose of the Rename operation in LevelDBβs storage interface, and how does your FDB implementation preserve its atomicity guarantee?
Rename is LevelDBβs way of atomically promoting a new SSTable (or MANIFEST) into production. During compaction, LevelDB writes the new SSTable to a temp file, then renames it to its final name. POSIX rename is atomic: either the old name or the new name is visible, never a half-written file. Our FDB implementation reads all chunks with the old file descriptor, writes them with the new file descriptor, and clears the old keys β all in one FDB transaction. FDBβs transaction atomicity provides the same guarantee: either the old keys or the new keys are visible, never both or neither.
Q: Why does LevelDB use a Write-Ahead Log, and is it still necessary when using FDB as the storage backend?
The WAL protects against crash scenarios where data was written to the in-memory MemTable but not yet flushed to an SSTable on disk. Without a WAL, a crash after the MemTable write but before the SSTable flush would lose those writes. With FDB as storage, our writer.Close() writes chunks to FDB in transactions. Each committed FDB transaction is durable (replicated to at least two machines). A crash after Close() returns has no data loss. The WALβs durability purpose is already provided by FDB. A production implementation would use a no-op WAL to skip the overhead.
Q: What is the 10 MB transaction limit in FDB, and what design patterns avoid hitting it?
FDB limits the total read + write size per transaction to approximately 10 MB to bound the memory required on Commit Proxies and to keep transaction resolution fast. Patterns to stay within the limit: (1) chunk large values (as we do with 64 KiB chunks), (2) break bulk writes into multiple transactions with cursor-based pagination, (3) configure LevelDBβs compaction to keep SSTable sizes small (< 2 MB), (4) use FDB atomic operations (tr.Add, tr.SetVersionstampedKey) where possible β atomic operations donβt count against the read portion of the limit.
Q: How would you extend this implementation to support multiple concurrent LevelDB instances sharing the same FDB cluster?
Give each LevelDB instance its own ns prefix. The FDB key space is naturally partitioned: ns1 + ... keys and ns2 + ... keys are completely disjoint. Multiple instances can read and write concurrently with no coordination overhead β FDBβs conflict detection only fires when two transactions write the same key, and different namespaces use different keys. The lock key (ns + tagLock) is also per-namespace, so locking one instance doesnβt affect others.
16. Bugs Encountered and Lessons Learned
Implementing storage.Storage from scratch surfaces several non-obvious
contracts that the goleveldb source doesnβt make obvious. These are real bugs
found while getting the demo to work end-to-end.
Bug 1 β storage.ErrNotExist Does Not Exist
Symptom:
fdbstorage/storage.go:265:27: undefined: storage.ErrNotExist
What happened:
The initial implementation tried to use storage.ErrNotExist (by analogy
with os.ErrNotExist), assuming goleveldb exported a sentinel error value
for βfile not foundβ. It doesnβt β goleveldb v1.0.0 exports no such symbol.
The contract (from the goleveldb source comments):
// Open opens file with the given 'file descriptor' read-only.
// Returns os.ErrNotExist error if the file does not exist.
Open(fd FileDesc) (Reader, error)
// GetMeta returns 'file descriptor' stored in meta.
// Returns os.ErrNotExist if meta doesn't store any 'file descriptor'.
GetMeta() (FileDesc, error)
The interface contract is written in English comments, not in types. Both
Open and GetMeta must signal absence with os.ErrNotExist β the standard
library sentinel, not a goleveldb one.
Fix: Use os.ErrNotExist directly everywhere the storage contract
requires βnot foundβ.
Bug 2 β GetMeta Returning a Custom Error on Empty Namespace
Symptom:
fdbstorage: no manifest set
exit status 1
What happened:
GetMeta read the manifest key from FDB. When the namespace was empty (first
run, no DB created yet), the key was nil. The original code returned:
return storage.FileDesc{}, errors.New("fdbstorage: no manifest set")
goleveldbβs Open distinguishes two cases after calling s.recover():
err = s.recover()
if err != nil {
if !os.IsNotExist(err) || s.o.GetErrorIfMissing() {
return // real error β abort
}
err = s.create() // not-exist β create a fresh DB β this is what we want
}
The custom error doesnβt satisfy os.IsNotExist, so goleveldb took the
βreal error β abortβ path and surfaced the message to the user.
Fix:
if b == nil {
return storage.FileDesc{}, os.ErrNotExist
}
Return the standard sentinel. goleveldb then calls s.create() and
initialises a fresh database.
Bug 3 β List() Always Returned Empty
Symptom:
First session wrote three keys successfully. Second session opened the DB
without error, but db.Get("apple") returned leveldb: not found.
What happened:
The List function is supposed to enumerate all files in the namespace.
goleveldb calls List(TypeJournal) during recovery to find WAL files that
need to be replayed. If the list is empty, no WAL is replayed β the memtable
stays empty β and all keys written in the previous session appear missing.
The bug was subtle. List always passed storage.TypeAll to the internal
range helper regardless of the ft argument:
func (s *Storage) List(ft storage.FileType) ([]storage.FileDesc, error) {
v, err := s.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
// BUG: always uses TypeAll as the range prefix byte
return rt.GetRange(s.metaRangeForType(storage.TypeAll), ...).GetSliceWithError()
})
storage.TypeAll is a bitmask (TypeManifest | TypeJournal | TypeTable | TypeTemp = 1 | 2 | 4 | 8 = 0x0F). It is not a real file type. The meta keys
in FDB use individual type bytes (0x01, 0x02, 0x04, 0x08). The range
starting at <ns> 0x01 0x0F matched nothing because 0x0F is larger than
all real type bytes.
The in-loop filter ft & thisFT == 0 was correct; only the FDB range prefix
was wrong.
Fix: Scan all meta keys (from <ns> tagFileMeta to <ns> tagFileMeta+1)
and let the existing per-item ft filter handle selection:
allMeta := fdb.KeyRange{
Begin: fdb.Key(append(append([]byte{}, s.ns...), tagFileMeta)),
End: fdb.Key(append(append([]byte{}, s.ns...), tagFileMeta+1)),
}
Lesson: A bitmask sentinel (TypeAll) and a concrete type byte are
different things. Never use a bitmask as a key prefix component.
Bug 4 β FDB-Persisted Lock Survives Process Crashes
Symptom:
fdbstorage: namespace already locked
exit status 1
on every run after any run that didnβt exit cleanly.
What happened:
The original Lock() wrote a key to FDB. Unlock() (called by goleveldb
inside db.Close()) cleared it. This worked as long as db.Close() ran.
But log.Fatal / log.Fatalf calls os.Exit(1), which does not run
deferred functions. The demoβs second session had:
db, err := leveldb.Open(stor, nil)
// ...
defer db.Close() // β registered but ...
v, err := db.Get([]byte(k), nil)
if err != nil {
log.Fatalf(...) // β os.Exit kills the process here; defer never runs
}
goleveldb stores the locker returned by Lock() and calls locker.Unlock()
inside db.Close(). When os.Exit fires, db.Close() is never called, the
unlock never happens, and the FDB lock key persists until the next manual
cleanup.
Two fixes applied:
-
Switch to an in-process lock β replace the FDB key with a
sync.Mutexflag on theStoragestruct. An in-process flag resets automatically every time the process starts; stale state from a crashed run is impossible. The trade-off is losing cross-process exclusion, which is acceptable for a single-process demo. -
Avoid
log.Fatalafterdefer db.Close()β extract the code into a function that returns anerror. Deferred cleanup runs normally when the function returns, even on the error path. The caller then doeslog.Fatalafter cleanup is complete.
Lesson: log.Fatal, log.Fatalf, os.Exit, and runtime.Goexit all
bypass defer. Any resource that must be released (DB handles, locks, network
connections) should be closed by a return-based error path, not a fatal exit
inside a function that registered defer.
Bug 5 β os.IsNotExist Ignores Custom .Is() Methods (Go 1.16+)
Symptom:
fdbstorage: file MANIFEST-000003 does not exist
exit status 1
after stale FDB state was left by a prior failed run.
What happened:
The initial fix for Bug 1 used a custom error type with an Is method:
type fileNotExistError struct{ fd storage.FileDesc }
func (e *fileNotExistError) Is(target error) bool {
return target == os.ErrNotExist
}
errors.Is(err, os.ErrNotExist) correctly returns true for this type.
However, goleveldbβs session.recover() uses os.IsNotExist(err), not
errors.Is. In Go 1.25, os.IsNotExist is implemented as:
func underlyingErrorIs(err, target error) bool {
err = underlyingError(err) // unwraps *PathError, *LinkError, *SyscallError only
if err == target { return true }
e, ok := err.(syscallErrorType)
return ok && e.Is(target) // only calls .Is() on syscall errors
}
The comment in the Go source is explicit: underlyingErrorIs βonly examines
syscall errorsβ to preserve historical behaviour. A custom error typeβs
Is method is called by errors.Is but not by os.IsNotExist.
Because os.IsNotExist returned false for fileNotExistError, goleveldb
surfaced the error directly instead of creating a fresh DB.
Fix: Use *os.PathError as the wrapper β os.IsNotExist explicitly
unwraps *PathError in its underlyingError switch:
return nil, &os.PathError{Op: "open", Path: fd.String(), Err: os.ErrNotExist}
underlyingError extracts Err (which is os.ErrNotExist), the == target
check passes, and os.IsNotExist returns true.
Lesson: errors.Is and os.IsNotExist are not equivalent in Go 1.13+.
errors.Is traverses the full chain calling .Is() methods. os.IsNotExist
only inspects a fixed set of OS error wrapper types. Always use
*os.PathError{Err: os.ErrNotExist} (not a custom Is method) when you need
os.IsNotExist compatibility.
Bug 6 β Stale FDB State Across Runs
Root cause: Every bug above caused the demo to exit abnormally, leaving partial or inconsistent FDB state: a manifest pointer referencing a file whose data chunks were never written, or data from a session that was never cleanly closed.
Fix: Add Storage.Wipe() β a single range-clear of the entire namespace β
and call it at demo startup. This makes each run deterministic. In a real
system, Wipe would be replaced with a proper recovery procedure:
- Check for a βrename-pendingβ key (Exercise 4 in this guide) and complete or roll back any in-flight rename.
- Use goleveldbβs
Recoverfunction (instead ofOpen) which reads whatever SSTables it can find and rebuilds the MANIFEST from scratch. - Remove orphaned files (those in FDB but not referenced by the MANIFEST) via
List+ cross-reference with the MANIFESTβs live file set.
Option B β SQLite VFS substrate on FoundationDB
- What a SQLite VFS actually needs
- Key layout
- Where the transactional magic lives
- Hooking this into a real SQLite
- Running the demo
- Whatβs deliberately omitted
Pattern: SQLite (or any pager-style engine) with FDB as the disk. We implement the byte-range storage primitive a SQLite VFS sits on top of β fixed-size pages keyed by page number, atomic page-level updates β and demonstrate it through a small Go API. Wiring the C-level VFS hooks is mechanical glue thatβs left for a follow-up.
What a SQLite VFS actually needs
SQLiteβs pager talks to βthe OSβ through a thin C interface defined in vfs.h. Boiled down, a VFS file handle exposes:
| SQLite call | What we map it to |
|---|---|
xRead(buf, n, offset) | File.ReadAt(buf, offset) |
xWrite(buf, n, offset) | File.WriteAt(buf, offset) |
xTruncate(size) | File.Truncate(size) |
xFileSize() | File.Size() |
xLock / xUnlock | File.Lock(holder) / File.Unlock(holder) |
xSync | no-op β every WriteAt is already durable in FDB |
SQLite always reads and writes in multiples of the page size (4096 by default) once itβs past the 100-byte file header, so storing one FDB KV per page is a natural fit and makes the pager-to-storage mapping 1:1.
Key layout
<ns> 0x00 -> uint64 BE file size in bytes
<ns> 0x01 <pageNum:uint64 BE> -> 4096-byte page
<ns> 0x02 -> lock holder name (or absent)
Where the transactional magic lives
The interesting method is WriteAt. For partial-page writes, we:
- Issue
tr.Getfor every affected page. - Merge the existing page bytes with the new bytes.
tr.Setthe resulting full page.- Update the file-size key if we grew.
All inside one FDB transaction. That gives SQLite a property it cannot get from a normal filesystem: multi-page writes are atomic. SQLite has elaborate journal/WAL machinery to recover from βwe crashed halfway through updating pages 17, 18, and 19.β On this VFS that recovery code becomes dead β either all three pages flipped or none did.
In practice youβd still set PRAGMA journal_mode = MEMORY (so SQLite skips
the rollback journal it doesnβt need) and rely on FDBβs transactional commit
as the single durability point.
Hooking this into a real SQLite
There are two paths:
- cgo + mattn/go-sqlite3 or zombiezen.com/go/sqlite: register a custom
sqlite3_vfswhosexRead/xWrite/...thunks call into ourpagestore.Filemethods via a CGO bridge. ~300 lines of glue. - modernc.org/sqlite: pure-Go SQLite. Its
vfssubpackage exposesRegister/VFStypes. Same wiring, no CGO.
Either way the interesting code is whatβs already here β the C/Go thunks add no further insight into how FDB serves as the storage tier.
Running the demo
cd option-b-sqlite
go mod tidy
go run ./demo -cluster ../fdb.cluster
Expected output:
After writing header (16 B): size=16
After 100-byte cross-page write at offset 4090: size=4190
Read 100 B back, all 'A'? true
Header preserved? "SQLite format 3\x00"
After truncate to 4096: size=4096
Lock contention as expected: pagestore: locked by "conn-A"
Lock handoff OK.
The cross-page write at offset 4090 is the key correctness test: it spans page 0 (bytes 4090β4095) and page 1 (bytes 4096β4189). The output above proves that:
- The pre-existing 16-byte header on page 0 was preserved during the read- modify-write merge.
- Both halves of the payload made it to disk.
- A subsequent
Truncate(4096)cleared page 1 entirely.
Whatβs deliberately omitted
- WAL mode. Skipping it forces SQLite into rollback-journal mode, which
emits ordinary writes only β exactly what our VFS supports. WAL would
require a second βshared-memoryβ backing store (
xShmMap) and is the single largest source of complexity in real VFS implementations. - Multi-process locking. The lock key works for cooperating clients but has no liveness guarantee on holder crash. Production code would combine it with FDB watches and a heartbeat.
- The C bridge itself. See
docs/README.mdabove for the wiring sketch.
Hitchhikerβs Guide β Option B: SQLite VFS over FoundationDB
- Table of Contents
- 1. SQLiteβs Architecture β From SQL to Bytes
- 2. The Virtual File System (VFS) β SQLiteβs Plugin Point
- 3. The Page Model β How SQLite Organizes Storage
- 4. Our pagestore β FDB as a Page Store
- 5. The Partial-Page Write Problem β And Its Atomic Solution
- 6. xSync is a No-Op β And Why Thatβs Correct
- 7. Locking β From POSIX Flock to FDB Keys
- 8. Journal Modes: DELETE, WAL, MEMORY
- 9. mvsqlite β The Production Version of This
- 10. Real-World Analogues
- 11. Exercises
- 12. Source Code Deep Dive β pagestore/pagestore.go
- 13. Production Considerations
- 14. Interview Questions β SQLite, VFS, and Page Models
The question this answers: βHow does SQLite actually read and write its database file? Can we replace the file with FoundationDB?β
The deeper question: βWhat is a database page, how does the pager work, and why is the page model the right abstraction for a VFS?β
Table of Contents
- SQLiteβs Architecture β From SQL to Bytes
- The Virtual File System (VFS) β SQLiteβs Plugin Point
- The Page Model β How SQLite Organizes Storage
- Our pagestore β FDB as a Page Store
- The Partial-Page Write Problem β And Its Atomic Solution
- xSync is a No-Op β And Why Thatβs Correct
- Locking β From POSIX Flock to FDB Keys
- Journal Modes: DELETE, WAL, MEMORY
- mvsqlite β The Production Version of This
- Real-World Analogues: libSQL, Litestream, LiteFS
- Exercises
1. SQLiteβs Architecture β From SQL to Bytes
SQLite processes queries through seven layers:
SQL text
β Tokenizer + Parser
Abstract Syntax Tree (AST)
β Code Generator
Bytecode program (VDBE instructions)
β Virtual Database Engine (VDBE)
B-tree operations (seek, insert, delete on B-tree pages)
β Pager
Page cache: reads/writes logical page numbers
β OS Interface (VFS)
File reads/writes at byte offsets
β Actual storage
We plug in at the VFS layer. Everything above (SQL parsing, the B-tree, the pager) runs as normal. The VFS is called for all I/O, and our implementation redirects those calls to FDB.
The key insight: SQLite doesnβt know or care whether the VFS talks to a local file, a network mount, or a distributed database. It only needs the VFS contract to be upheld.
2. The Virtual File System (VFS) β SQLiteβs Plugin Point
The VFS is documented in SQLiteβs C API. The key functions (C signatures simplified):
// File operations (called on each open file):
int xRead(file, buf, amount, offset); // read `amount` bytes at `offset`
int xWrite(file, buf, amount, offset); // write `amount` bytes at `offset`
int xTruncate(file, size); // truncate to `size` bytes
int xSync(file, flags); // flush to durable storage
int xFileSize(file, *size); // return current size
int xLock(file, locktype); // acquire SHARED/EXCLUSIVE/etc
int xUnlock(file, locktype); // release lock
int xCheckReservedLock(file, *result); // is anyone holding RESERVED lock?
int xShmMap(file, region, size, ...); // map shared memory region (WAL mode)
Our Go pagestore implements the subset needed for non-WAL mode:
ReadAt, WriteAt, Truncate, FileSize, Lock, Unlock.
Why a Go struct instead of a C VFS?
A real SQLite VFS requires implementing C structs (sqlite3_vfs,
sqlite3_file) and using CGO to register them. This is correct but adds
~200 lines of glue code that would obscure the core concept. Our pagestore
is a Go struct that exercises the exact same I/O patterns as SQLite would use.
The demo calls ReadAt/WriteAt directly, simulating what the SQLite pager
would call through the VFS.
3. The Page Model β How SQLite Organizes Storage
SQLiteβs database file is divided into fixed-size pages (default 4096 bytes). Every structure in a SQLite database β B-tree interior nodes, B-tree leaf nodes, overflow pages, free-list pages β is exactly one page. The pager manages a cache of these pages and issues I/O to the VFS in page-aligned operations.
SQLite database file layout:
Offset 0 β 4095: Page 1 (database header + B-tree root)
Offset 4096 β 8191: Page 2
Offset 8192 β 12287: Page 3
...
Offset (N-1)*4096 β N*4096-1: Page N
Page 1 is special: its first 100 bytes are the database header:
Offset 0: "SQLite format 3\000" (16 bytes, magic string)
Offset 16: page size (2 bytes)
Offset 18: file format write version (1 byte)
Offset 19: file format read version (1 byte)
...
Offset 28: file change counter (4 bytes)
...
Offset 52: schema format number (4 bytes)
...
The partial-page write problem arises here: the header is 100 bytes, but
SQLite might write just the header (100 bytes at offset 0), while the rest
of page 1 (bytes 100β4095) contains B-tree data. An xWrite(file, header, 100, 0) call writes only 100 bytes β not the full page.
4. Our pagestore β FDB as a Page Store
We store pages as FDB keys:
const PageSize = 4096
// Page data:
// ns + 0x01 + pageNum(uint64 big-endian) β 4096 bytes
// Database size (in pages):
// ns + 0x00 β uint64 (big-endian)
// Exclusive lock:
// ns + 0x02 β "locked" (any non-empty value)
Page numbers:
SQLite page numbers are 1-based (page 1 is the first page). We store them as 0-padded big-endian uint64s:
Page 1: ns + 0x01 + 0000000000000001
Page 2: ns + 0x01 + 0000000000000002
Page 1000: ns + 0x01 + 00000000000003E8
Big-endian encoding ensures that a GetRange over ns+0x01+0 to
ns+0x01+FFFFFFFFFFFFFFFF returns pages in ascending page-number order β
useful for scanning the entire database.
Size tracking:
SQLite calls xFileSize to determine how many pages exist. We track this
explicitly with a size key rather than scanning for the highest-numbered
page key (which would be an O(N) scan). The size key is updated atomically
in the same transaction as every WriteAt and Truncate.
5. The Partial-Page Write Problem β And Its Atomic Solution
This is the trickiest part of implementing a page-based storage engine.
SQLiteβs VFS contract says:
xWrite(offset, amount, data):
Write `amount` bytes starting at `offset`.
`offset` and `amount` may be any values (not necessarily page-aligned).
Examples of partial-page writes:
- Write header (100 bytes at offset 0) to page 1
- Write a record that spans a 4-byte boundary at the end of a page
- Write a single integer (4 bytes at offset 12) to a page
We cannot simply map WriteAt(offset, data) to Set(pageKey(offset/4096), data) because that would overwrite the entire 4096-byte page with only
the partial data β corrupting the rest of the page.
Our solution: read-modify-write inside one transaction.
func (p *PageStore) WriteAt(data []byte, off int64) (int, error) {
firstPage := off / PageSize
lastPage := (off + int64(len(data)) - 1) / PageSize
_, err := p.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
for pageNum := firstPage; pageNum <= lastPage; pageNum++ {
// 1. Read current page content (or zeros if it doesn't exist yet)
existing, _ := tr.Get(p.pageKey(pageNum)).Get()
page := make([]byte, PageSize)
copy(page, existing) // pad to 4096 with zeros if shorter
// 2. Compute which bytes of `data` go into this page
pageStart := pageNum * PageSize
pageEnd := pageStart + PageSize
writeStart := max(off, pageStart) - pageStart
writeEnd := min(off+int64(len(data)), pageEnd) - pageStart
dataStart := max(off, pageStart) - off
dataEnd := min(off+int64(len(data)), pageEnd) - off
// 3. Overlay the new bytes onto the existing page
copy(page[writeStart:writeEnd], data[dataStart:dataEnd])
// 4. Write the modified page back
tr.Set(p.pageKey(pageNum), page)
}
// 5. Update size key if this write extends the file
...
return nil, nil
})
return len(data), err
}
Steps 1β4 happen inside one FDB transaction. The read and the write are atomic: no concurrent writer can modify the page between our read and write. No partial update is visible to other transactions.
The atomicity guarantee:
If the process crashes after step 1 (read) but before step 4 (write), the transaction is never committed. FDB abandons it. The page is unchanged. No corruption.
If two concurrent writers both try to read-modify-write the same page, FDBβs conflict detection will cause one to retry. The second writer will re-read the page (now including the first writerβs change) and overlay its own data on top. Correct behavior, no coordination required.
6. xSync is a No-Op β And Why Thatβs Correct
SQLite calls xSync to tell the VFS: βmake sure all previous writes are
durable on physical storage before returning.β On a normal filesystem, this
calls fsync() which flushes the OS page cache to the disk controller and
waits for the disk to confirm durability.
Why do we implement it as a no-op?
FDBβs Transact is synchronous and durable.
When WriteAt calls p.db.Transact(...) and it returns without error, the
data is already:
- Written to FDBβs transaction log on
f+1machines (durably replicated) - Committed (visible to new transactions)
By the time WriteAt returns to the SQLite pager, the durability guarantee
is already satisfied β stronger than fsync on a local disk.
xSync is a no-op because the work it would do has already been done
during xWrite. There is nothing left to flush.
Analogy: Imagine a bank transfer. xWrite is βdebit Alice, credit Bob,
commit to ledger.β xSync is βmake sure the ledger is durable.β If the
ledger is already in a replicated distributed database, xSync has nothing
to do.
7. Locking β From POSIX Flock to FDB Keys
SQLite uses file locking to coordinate concurrent access. The POSIX lock levels, in order of increasing exclusivity:
UNLOCKED β SHARED β RESERVED β PENDING β EXCLUSIVE
- SHARED: reader has this. Many readers can hold SHARED simultaneously.
- RESERVED: writer intending to modify. One writer can hold RESERVED while readers continue holding SHARED.
- PENDING: writer waiting for all SHARED holders to release.
- EXCLUSIVE: sole writer, no readers. Required before writing pages.
A full VFS implementation would map these levels to FDB lock keys with appropriate semantics (e.g., a counter for SHARED, a flag for EXCLUSIVE).
Our implementation is simplified: we use one βexclusive lockβ key. Setting it means exclusive ownership; clearing it means unlocked. This correctly implements single-writer semantics but doesnβt allow concurrent readers.
For multi-reader/single-writer, a production implementation would:
SHARED lock: counter key (increment on lock, decrement on unlock)
EXCLUSIVE lock: flag key
β acquire EXCLUSIVE: check counter == 0, set flag key
β acquire SHARED: check flag key is unset, increment counter
Both checks-and-sets would use FDB transactions to ensure atomicity.
8. Journal Modes: DELETE, WAL, MEMORY
SQLite has several journal modes, selected with PRAGMA journal_mode=MODE.
The journal is SQLiteβs crash recovery mechanism (distinct from FDBβs own
recovery).
DELETE (default rollback journal): Before modifying a page, SQLite copies the original page content to a separate journal file. If a crash occurs during a write, SQLite replays the journal to restore the original page content.
With FDB, this is redundant: FDBβs transactions provide rollback for free.
A partial WriteAt that crashes mid-transaction rolls back automatically.
The journal file would be stored in FDB via our VFS, adding overhead for
no benefit.
WAL (Write-Ahead Log): Instead of copying original pages before modification, SQLite appends new page versions to a WAL file. Readers check the WAL before reading the main database file. A βcheckpointβ operation copies WAL pages back to the main file.
WAL mode requires xShmMap β shared memory for the WAL index. This is a
memory-mapped file that multiple processes share to coordinate which WAL
frames are valid. Implementing xShmMap in FDB would require:
- A small FDB key range to store the WAL index state.
- Synchronized access to that state via FDB transactions. This is complex but possible β mvsqlite does it.
MEMORY: SQLite uses an in-memory journal, not written to any file. Rollback is possible within a transaction but not after a crash.
For our FDB VFS, PRAGMA journal_mode=MEMORY is the right choice: SQLite
wonβt try to create journal files (which would trigger extra VFS calls), and
crash recovery is handled by FDB. This is what our demo uses implicitly by
not specifying a journal mode (we would need to open the database and run
PRAGMA journal_mode=MEMORY before any writes).
9. mvsqlite β The Production Version of This
mvsqlite (by losfair, Rust) is the production-grade implementation of the same idea. Its architecture:
Namespace mapping: Each SQLite βdatabase fileβ path is mapped to an FDB namespace prefix. Multiple processes can open the same βfileβ (namespace) simultaneously with MVCC isolation.
Page-level MVCC: mvsqlite keeps multiple versions of each page, similar to how PostgreSQL keeps multiple row versions. When a reader opens the database, it captures an FDB read version. Page reads are served from FDB at that version. New writes create new page versions. Old versions are kept until all readers that need them complete.
Our pagestore is a simplified, non-MVCC version: every read sees the latest
page version. Adding page-level MVCC would require:
ns + 0x01 + pageNum + version β 4096 bytes (instead of just pageNum)
With a cleanup process that removes old versions when no readers hold them.
Write-ahead log in FDB: mvsqlite implements WAL mode by storing the WAL log pages in FDB itself:
ns + 0x03 + txnId + pageNum β 4096 bytes (uncommitted WAL pages)
ns + 0x04 + txnId β commit record
This allows SQLiteβs WAL mode to work without xShmMap (the shared memory
region) β instead, the WAL index state lives in FDB and is accessed via
transactions.
10. Real-World Analogues
libSQL (Turso)
libSQL is a fork of SQLite that adds multi-tenancy and remote storage. Tursoβs cloud product stores SQLite databases in a distributed object store (similar to our FDB approach). The architecture:
- Local replicas for low-latency reads
- A primary writes to the distributed store
- Followers pull changes from the store and apply them locally
Litestream
Litestream continuously replicates SQLite databases to S3 or other cloud storage by intercepting the WAL. It monitors the SQLite WAL file and copies new frames to cloud storage in near-real-time. Restore is done by downloading frames and applying them.
Litestream does NOT modify SQLiteβs VFS β it runs as a separate process that monitors the WAL file. This is simpler but means it canβt provide multi-writer consistency (only one process can write to a SQLite database at a time).
LiteFS (Fly.io)
LiteFS mounts a FUSE filesystem that intercepts SQLite writes, replicates them to a primary node, and distributes to replicas. It does intercept at the filesystem level (FUSE = Filesystem in Userspace), which is essentially implementing the VFS pattern at the OS level.
The Patternβs Significance
All of these tools (mvsqlite, libSQL, Litestream, LiteFS) are attacking the same problem: SQLite is an excellent embedded database, but itβs tied to a single file on a single machine. The solution in each case is to intercept the storage layer and redirect I/O to a distributed, replicated system.
Our pagestore is the minimal proof-of-concept for this idea: 150 lines of
Go that demonstrate the core page-store primitives.
11. Exercises
Exercise 1 β Page Cache
Add an in-process LRU page cache:
type PageStore struct {
...
cache *lru.Cache // maps pageNum β [PageSize]byte
}
ReadAt checks the cache before going to FDB. WriteAt updates the cache
after writing to FDB. Evict on Truncate.
Measure the cache hit rate for a typical SQLite workload (a mix of reads and writes). Youβll find that B-tree root pages have very high hit rates β theyβre accessed on every query.
Exercise 2 β Implement xShmMap for WAL Mode
WAL mode requires shared memory for the WAL index. In mvsqlite, this is done with FDB keys. Implement a simplified version:
- Add a βWAL regionβ subspace:
ns + 0x03 + regionNum β 32768 bytes xShmMap(region, size)reads the FDB key and returns a[]bytexShmBarrier()writes the modified byte slice back to FDBxShmLockmaps to a FDB lock key per-region
With this, enable PRAGMA journal_mode=WAL in the demo and verify reads
and writes still work.
Exercise 3 β Multi-Version Pages
Change the page key layout to:
ns + 0x01 + pageNum + readVersion β 4096 bytes
WriteAt reads the current version, writes a new version at the current
FDB commit version. ReadAt scans backwards from the requested read version
to find the most recent page version β€ that version.
Add a Compact(olderThan version) that clears all page versions older than
a given version. This is the MVCC GC mechanism.
Exercise 4 β Register as a Real SQLite VFS
Using CGO, implement the actual sqlite3_vfs and sqlite3_file C structs
backed by our pagestore. Register the VFS with sqlite3_vfs_register.
Open a real SQLite connection using this VFS:
db, err := sql.Open("sqlite3", "file:demo.db?vfs=fdbvfs")
db.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)")
db.Exec("INSERT INTO t VALUES (1, 'hello')")
All I/O will go through pagestore to FDB. You have now replaced SQLiteβs
storage engine while keeping the full SQL query interface.
Exercise 5 β Multi-Database Isolation
The pagestore uses a namespace prefix to isolate one database. Add a
DatabaseManager that:
- Lists all databases (range scan over a well-known metadata prefix)
- Creates a new database (allocates a namespace, writes a metadata entry)
- Deletes a database (one
ClearRange+ metadata clear) - Returns a
pagestorefor a given database name
12. Source Code Deep Dive β pagestore/pagestore.go
The File Struct
type File struct {
db fdb.Database
ns []byte
mu sync.Mutex
}
ns is the namespace byte prefix. mu protects concurrent calls to WriteAt and Truncate from a single File instance (though FDBβs own transactions provide the real isolation for concurrent processes).
Key Construction
func (f *File) sizeKey() fdb.Key {
key := make([]byte, len(f.ns)+1)
copy(key, f.ns)
key[len(f.ns)] = 0x00
return fdb.Key(key)
}
func (f *File) pageKey(pageNum int64) fdb.Key {
key := make([]byte, len(f.ns)+9)
copy(key, f.ns)
key[len(f.ns)] = 0x01
binary.BigEndian.PutUint64(key[len(f.ns)+1:], uint64(pageNum))
return fdb.Key(key)
}
sizeKey() stores the total file size in bytes. pageKey(n) stores the 4096-byte page at page number n. The big-endian 8-byte encoding of pageNum ensures that GetRange over sizeKey to the end of the 0x01 subspace returns pages in ascending page-number order.
ReadAt β Handling Partial-Page Reads
func (f *File) ReadAt(p []byte, off int64) (int, error) {
firstPage := off / PageSize
lastPage := (off + int64(len(p)) - 1) / PageSize
futures := make([]fdb.FutureByteSlice, lastPage-firstPage+1)
_, err := f.db.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
for i := firstPage; i <= lastPage; i++ {
futures[i-firstPage] = rt.Get(f.pageKey(i))
}
return nil, nil
})
// Wait for all futures and assemble the result
for i, fut := range futures {
pageData, _ := fut.Get()
// copy relevant bytes from page into p...
}
}
Pipelining multiple page reads: All rt.Get(pageKey(i)) calls are issued inside one ReadTransact closure. FDB sends all read requests to the storage servers before waiting for any response. If the read spans 3 pages (common for reads that cross page boundaries), all 3 are fetched in one round-trip. Without pipelining, each page would require a separate round-trip β 3x the latency.
This is the same pipelining pattern used in option-c-record-layerβs LookupByIndex.
WriteAt β The Read-Modify-Write Pattern
func (f *File) WriteAt(p []byte, off int64) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
firstPage := off / PageSize
lastPage := (off + int64(len(p)) - 1) / PageSize
_, err := f.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
// Issue all reads first (pipelined)
futures := make([]fdb.FutureByteSlice, lastPage-firstPage+1)
for i := firstPage; i <= lastPage; i++ {
futures[i-firstPage] = tr.Get(f.pageKey(i))
}
sizeF := tr.Get(f.sizeKey())
// Wait for reads and apply writes
for i := firstPage; i <= lastPage; i++ {
existing, _ := futures[i-firstPage].Get()
page := make([]byte, PageSize)
copy(page, existing)
// overlay p bytes onto page...
tr.Set(f.pageKey(i), page)
}
// Update size
curSize := int64(binary.BigEndian.Uint64(sizeF.MustGet()))
newEnd := off + int64(len(p))
if newEnd > curSize {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(newEnd))
tr.Set(f.sizeKey(), b)
}
return nil, nil
})
return len(p), err
}
The read futures are pipelined: all tr.Get() calls are issued before any .Get() is called to block. This means all page reads happen in parallel β one round-trip for up to N pages regardless of N.
The size update is in the same transaction: The page writes and the size update are atomic. If the process crashes mid-transaction, FDB abandons it: neither the page updates nor the size update are applied. The database remains in its previous state β consistent, readable, no corruption.
pageRange β For Bulk Operations
func (f *File) pageRange() fdb.KeyRange {
begin := f.pageKey(0)
end := make([]byte, len(f.ns)+1)
copy(end, f.ns)
end[len(f.ns)] = 0x02 // one past the page subspace tag (0x01)
return fdb.KeyRange{Begin: begin, End: fdb.Key(end)}
}
Used by Truncate to clear all pages above the new size:
func (f *File) Truncate(size int64) error {
newLastPage := size / PageSize
_, err := f.db.Transact(func(tr fdb.Transaction) (interface{}, error) {
// Clear all pages above newLastPage
clearBegin := f.pageKey(newLastPage + 1)
tr.ClearRange(fdb.KeyRange{Begin: clearBegin, End: f.pageRange().End})
// Update size
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(size))
tr.Set(f.sizeKey(), b)
return nil, nil
})
return err
}
One ClearRange atomically removes all pages above the new size. This is O(1) in FDB key operations (one range-clear instruction), even if there are thousands of pages to clear. This is vastly more efficient than deleting pages one by one.
13. Production Considerations
13.1 Page Size Selection
The default SQLite page size is 4096 bytes. You can change it with PRAGMA page_size=N (must be set before the first write). Larger pages reduce the number of B-tree levels and improve scan performance on large tables, but increase read amplification for small point lookups. For an FDB-backed store:
- 4096 bytes (default): 4096-byte FDB values. Small key overhead. Good for mixed workloads.
- 8192 bytes: 8192-byte values. Better for large sequential scans.
- 65536 bytes (maximum SQLite page size): 64 KB values. Below FDBβs 100 KB limit. Excellent for large sequential scans, but poor for point lookups.
For most use cases, 4096 is the right choice. Match it to your workloadβs read/write unit.
13.2 Hot Page Contention
Page 1 contains the database header and the root page of the main B-tree. Every query touches page 1. Every transaction increments the βfile change counterβ in the header. This means every write transaction does a read-modify-write on page 1 β even if the query doesnβt touch any user data on page 1 (e.g., inserting into a deep table touches only leaf pages and page 1 for the change counter).
In a high-concurrency write workload, this is a hot key. FDBβs conflict detection will serialize all transactions that write to page 1 β reducing concurrency.
Solution (mvsqliteβs approach): Move the change counter out of the page and into a separate FDB key. Use FDBβs atomic add to increment it without a read-modify-write. Since atomic add is commutative, it doesnβt cause conflicts. The page itself (minus the counter) is written only when actual B-tree structure changes.
13.3 Crash Recovery Testing
With FDBβs transactional guarantees, the traditional SQLite recovery path (re-applying the rollback journal) is never needed. But itβs worth testing:
- Write some data.
- Kill the process mid-write (use
kill -9oros.Exit(1)inside aWriteAt). - Restart the process and open the database.
- Verify the database is in a consistent state (either the write happened or it didnβt, no partial state).
FDBβs transaction semantics make this trivially correct, but testing it gives you confidence that your implementation upholds the contract.
14. Interview Questions β SQLite, VFS, and Page Models
Q: What is a database page and why do databases use a fixed page size?
A page is the fundamental unit of I/O and storage in a database. Fixed page sizes allow: (1) simple address calculation β page N starts at byte (N-1) * pageSize; (2) alignment with OS page sizes (4 KiB for virtual memory, matching SQLiteβs default); (3) buffer pool management β a page cache stores exactly one page per slot. Variable-size records are packed into pages; a B-tree node is exactly one page. The fixed size ensures that a B-tree node read is always one I/O operation, not an I/O per record.
Q: What is the partial-page write problem, and how does your FDB implementation solve it?
SQLiteβs VFS xWrite can write any byte range β not necessarily page-aligned. If a 100-byte write starts at offset 0, it overwrites bytes 0β99 of page 1, but bytes 100β4095 are unchanged. An implementation that naively maps this to Set(page1Key, 100_bytes) would corrupt page 1 (losing bytes 100β4095). Our solution: read the existing page, overlay the new bytes, write the full 4096-byte page back β in one FDB transaction. The transaction atomicity ensures that no other writer sees a partial page and that a crash during the operation leaves the page unchanged.
Q: Why is xSync a no-op in your VFS implementation?
xSync requests durability: βflush all previous writes to stable storage.β Our WriteAt implementation uses fdb.Transact which does not return until the write is committed and replicated to FDBβs transaction log. The commit is synchronous and durable by the time WriteAt returns. There is nothing left for xSync to flush. The durability guarantee of xSync is already satisfied by the end of WriteAt.
Q: How does SQLite locking work, and how would you implement a multi-reader single-writer lock in FDB?
SQLite uses 5 lock levels. For multi-reader single-writer: maintain a SHARED lock counter (number of active readers) and an EXCLUSIVE lock flag. Acquire SHARED: check exclusive flag is unset, increment counter (in one FDB transaction). Acquire EXCLUSIVE: check counter == 0, set exclusive flag (in one FDB transaction). Release SHARED: decrement counter. Release EXCLUSIVE: clear flag. All check-and-set operations are atomic in FDB transactions, so no intermediate state is observable. A would-be exclusive lock holder that sees counter > 0 must retry (wait for readers to finish).
This is the NamespaceManager concept in mvsqlite, and itβs how a
multi-tenant SQLite service would work.
Option C β Record Layer over FoundationDB
- What problem this solves
- Files
- Key layout
- Why itβs transactional by construction
- Mapping
LookupByIndexto FDB ops - Running
- What this layer omits compared to fdb-record-layer
Pattern: βstructured records + secondary indexes, native FDBβ β the same idea Appleβs
fdb-record-layeris built around, distilled to ~250 lines of Go so you can read the whole thing in one sitting.
What problem this solves
FDBβs wire-level API is ordered KV. Most applications want records (a name, a city, an email) plus queries like βfind users in Parisβ. A record layer sits in between:
- Records are serialized blobs stored under a primary key.
- Indexes are extra KV entries keyed by (field, value, pk) so range scans on the index subspace return matching primary keys.
- Both updates happen in one FDB transaction, so the index is never out of sync with the records β even under concurrent writers.
Files
recordlayer/
encoding.go Key layouts for records (tag 0x00) and indexes (tag 0x01)
msgpack.go Wrapper around github.com/vmihailenco/msgpack
store.go Open / PutRecord / GetRecord / DeleteRecord / ScanRecords
index.go LookupByIndex (range-scan index β batch-read records)
demo/main.go Users keyed by id, indexed by city
Key layout
records: <ns> 0x00 <schemaName> 0x00 <pk> -> msgpack(record)
indexes: <ns> 0x01 <schemaName> 0x00 <fieldName> 0x00 <value> 0x00 <pk> -> ""
Splitting records and indexes into two top-level βtagsβ keeps each subspace
range-scannable without touching the other. Integers are written as
sign-bit-flipped big-endian (see encodeIndexValue) so negative values sort
before positive in the index β a tiny but useful property when you want range
queries like βage >= 18β.
Why itβs transactional by construction
PutRecord does, inside one db.Transact:
- Read the previous version of the record (if any).
- For each indexed field that changed or was removed,
Clearthe old index entry. Setthe new record bytes.Setfresh index entries for the new field values.
Because FDB transactions are serializable, no other client can observe a state where the record was updated but its indexes were not. This is the single biggest reason to build atop FDB rather than a non-transactional KV.
Mapping LookupByIndex to FDB ops
db.ReadTransact(rt -> {
idxKVs := rt.GetRange(indexPrefix(schema, field, value)) // ordered scan
for each idxKV:
pendings[i] = rt.Get(recordKey(schema, pk)) // pipelined
for each pending: collect record
})
The pipeline trick (issuing all rt.Get calls before awaiting any) lets FDB
overlap network round-trips. Latency β slowest single read instead of
sum-of-reads.
Running
- Bring up FDB and bootstrap (see top-level README).
cd option-c-record-layer && go run ./demo -cluster ../fdb.cluster
Expected output:
All users (PK order):
u1 -> map[city:Paris name:Alice]
u2 -> map[city:Tokyo name:Bob]
u3 -> map[city:Paris name:Carol]
Lookup city=Paris (via secondary index):
u1 -> map[city:Paris name:Alice]
u3 -> map[city:Paris name:Carol]
After moving Alice to Tokyo:
Lookup city=Paris:
u3 -> map[city:Paris name:Carol]
Lookup city=Tokyo:
u1 -> map[city:Tokyo name:Alice]
u2 -> map[city:Tokyo name:Bob]
What this layer omits compared to fdb-record-layer
- Schema evolution / Protobuf descriptors.
- Index definitions like
COUNT,MAX,SUMaggregates. - Query planner over multiple indexes.
- Versioned records and meta-data subspace.
But the storage shape and atomicity story are exactly the same.
Hitchhikerβs Guide β Option C: Record Layer with Secondary Indexes
- Table of Contents
- 1. What the Record Layer Is
- 2. Records vs Indexes: Two Subspaces, One Truth
- 3. The Index Key Format β Anatomy of a Lookup Key
- 4. PutRecord β Read-Modify-Write as a Pattern
- 5. LookupByIndex β Pipelining Explained
- 6. The encodeInt64 Sign-Bit Trick (Derived from First Principles)
- 7. Index Consistency: The Core Guarantee
- 8. The Apple Story: fdb-record-layer and CloudKit
- 9. Real-World Analogues
- 10. Exercises
- 11. Source Code Deep Dive
- 12. Production Considerations
- 13. Interview Questions β Secondary Indexes and Record Layers
The question this answers: βHow does a database maintain secondary indexes so they always agree with the primary data β even under concurrent writes?β
The deeper question: βWhat does Apple actually do inside iCloud, and why did they choose FoundationDB as the substrate?β
Table of Contents
- What the Record Layer Is
- Records vs Indexes: Two Subspaces, One Truth
- The Index Key Format β Anatomy of a Lookup Key
- PutRecord β Read-Modify-Write as a Pattern
- LookupByIndex β Pipelining Explained
- The encodeInt64 Sign-Bit Trick (Derived from First Principles)
- Index Consistency: The Core Guarantee
- The Apple Story: fdb-record-layer and CloudKit
- Real-World Analogues: MySQL, PostgreSQL, MongoDB
- Exercises
1. What the Record Layer Is
The FoundationDB Record Layer is a Java library open-sourced by Apple in 2019. It provides a record-oriented storage layer above FDB with:
- Typed records using Protobuf descriptors
- Declarative indexes (define once; maintenance is automatic)
- A query planner that compiles predicates into index range scans
- Schema evolution without downtime
- Change feeds via versionstamped keys
Our implementation is a Go reimagining: records are map[string]any
(instead of Protobuf messages), indexes are field names (instead of
declarative index definitions), and there is no query planner β you call
LookupByIndex directly. But every core concept is the same.
Why a βrecord layerβ and not just βa KV storeβ?
The moment you have more than one way to find a record, you need an index. The moment you have an index, you have two copies of the same data in two different shapes. The moment you have two copies, you need a protocol for keeping them consistent. The record layer is that protocol.
2. Records vs Indexes: Two Subspaces, One Truth
Our key space uses two tag bytes to separate the two subspaces:
Records subspace:
<ns> + 0x00 + <schemaName> + 0x00 + <pk>
β value: msgpack(record)
Indexes subspace:
<ns> + 0x01 + <schemaName> + 0x00 + <fieldName> + 0x00 + <encodedFieldValue> + 0x00 + <pk>
β value: "" (empty β the key IS the data)
Example with namespace "demo", schema "user", user Alice (pk="alice")
with field city="Paris":
Records:
64 65 6d 6f 00 75 73 65 72 00 61 6c 69 63 65
"demo" 00 "user" 00 "alice"
β msgpack({id:"alice", name:"Alice", city:"Paris"})
Indexes (city field):
64 65 6d 6f 01 75 73 65 72 00 63 69 74 79 00 50 61 72 69 73 00 61 6c 69 63 65
"demo" 01 "user" 00 "city" 00 "Paris" 00 "alice"
β "" (empty value)
Why the value is empty for indexes:
The index key encodes everything you need to find the record: which schema, which field, what value, and what the PK is. The value at an index key carries no additional information. This is identical to how MySQLβs InnoDB secondary indexes work: the B-tree leaf stores just the clustered PK, not the full row.
Why the subspace separation matters:
- A full table scan (read all records) =
GetRange(ns+0x00+schema, ns+0x00+schema+0x01)β hits only records, not indexes. - An index scan (find all users where city=βParisβ) =
GetRange(ns+0x01+schema+0x00+city+0x00+Paris, ns+0x01+schema+0x00+city+0x00+Paris+0x01)β hits only that index column/value, not other indexes or records. - Clearing all indexes for a schema =
ClearRange(ns+0x01+schema, ns+0x01+schema+0x01).
With one tag byte, ranges are perfectly clean. Without it, youβd need carefully chosen sentinels and youβd still risk collisions.
3. The Index Key Format β Anatomy of a Lookup Key
Letβs trace exactly what key is written for each user:
Alice (pk="alice", city="Paris"):
index key = ns + 0x01 + "user" + 0x00 + "city" + 0x00 + "Paris" + 0x00 + "alice"
Bob (pk="bob", city="Tokyo"):
index key = ns + 0x01 + "user" + 0x00 + "city" + 0x00 + "Tokyo" + 0x00 + "bob"
Carol (pk="carol", city="Paris"):
index key = ns + 0x01 + "user" + 0x00 + "city" + 0x00 + "Paris" + 0x00 + "carol"
In FDBβs sorted key space, these appear as:
(sorted by bytes)
...
ns+0x01+"user"+0x00+"city"+0x00+"Paris"+0x00+"alice" β ""
ns+0x01+"user"+0x00+"city"+0x00+"Paris"+0x00+"carol" β ""
ns+0x01+"user"+0x00+"city"+0x00+"Tokyo"+0x00+"bob" β ""
...
A GetRange for city="Paris" scans from ...+"Paris"+0x00 to
...+"Paris"+0x01, returning exactly two keys (Alice and Carol). The PKs
("alice", "carol") are embedded in the key suffix. This is the index scan.
The extractPkFromIndexKey function:
Given the raw FDB index key, we need to extract just the PK suffix. The PK
starts after the last 0x00 byte:
func extractPkFromIndexKey(key fdb.Key) []byte {
for i := len(key) - 1; i >= 0; i-- {
if key[i] == 0x00 {
return key[i+1:]
}
}
return nil
}
We scan backwards because the PK itself might contain 0x00 bytes if it
was a binary key. Scanning backwards from the end finds the last 0x00,
which is the separator between the encoded field value and the PK. This is
safe because the encoded field value ends before that last 0x00, and the
PK follows.
A subtle limitation: if the PK itself can contain 0x00, this parsing
is ambiguous. A production implementation uses the Tuple encoding, which
escapes 0x00 bytes within strings (\x00\xff) so the separator \x00\x00
is unambiguous.
4. PutRecord β Read-Modify-Write as a Pattern
This is the most important function in the record layer. Letβs walk through it completely:
func (s *Store) PutRecord(schema Schema, pk string, rec Record) error {
_, err := s.fdb.Transact(func(tr fdb.Transaction) (interface{}, error) {
// Step 1: Read the OLD record (if it exists)
oldBytes, _ := tr.Get(recordKey(s.ns, schema.Name, pk)).Get()
// Step 2: Decode the old record
var oldRec Record
if len(oldBytes) > 0 {
msgpack.Unmarshal(oldBytes, &oldRec)
}
// Step 3: Remove OLD index entries
for _, field := range schema.Indexes {
if oldVal, ok := oldRec[field]; ok {
tr.Clear(indexKey(s.ns, schema.Name, field, oldVal, pk))
}
}
// Step 4: Write the NEW record
encoded, _ := msgpack.Marshal(rec)
tr.Set(recordKey(s.ns, schema.Name, pk), encoded)
// Step 5: Write NEW index entries
for _, field := range schema.Indexes {
if newVal, ok := rec[field]; ok {
tr.Set(indexKey(s.ns, schema.Name, field, newVal, pk), []byte{})
}
}
return nil, nil
})
return err
}
Why must you read the old record before writing the new one?
Consider Alice moving from Paris to Tokyo:
Before: {id:"alice", name:"Alice", city:"Paris"}
After: {id:"alice", name:"Alice", city:"Tokyo"}
We need to:
- Remove the old index entry for
city=Paris, pk=alice - Add a new index entry for
city=Tokyo, pk=alice - Write the new record
We cannot do step 1 without knowing that Alice was previously in Paris. That information lives in the old record. So we read it first.
Why inside one transaction?
If we split this into two transactions:
Transaction 1: read old record, decide to clear Paris index
Transaction 2: clear Paris index, write new record, write Tokyo index
A crash between T1 and T2 leaves the Paris index entry pointing to a record that now says Tokyo. This is a phantom index entry β a stale pointer that silently returns wrong results.
With all steps in one transaction: either the entire operation commits (both index update and record update) or neither does. The index and the record always agree.
This is what database people mean when they say atomicity eliminates a class of bugs. Itβs not just about performance; itβs about correctness.
The cost: one extra read per update.
Every PutRecord does one Get (the old record) before writing. This adds
a read-version conflict key for the recordβs current PK slot. If two writers
try to update the same record concurrently, one will get a conflict and retry.
This is acceptable and correct β the alternative (no-conflict update) risks
interleaving index writes.
5. LookupByIndex β Pipelining Explained
This is where we use FDBβs pipelining to make multi-key lookups efficient.
The naive approach (one round-trip per record):
// BAD: N sequential round-trips for N results
pks := getIndexScan(...) // one round-trip: get all PKs from index
for _, pk := range pks {
rec := getRecord(pk) // one round-trip each: N round-trips total
results = append(results, rec)
}
For N=100, this is 101 network round-trips. At 1ms per round-trip, thatβs ~100ms for a simple lookup. Unacceptable.
The pipelined approach (two round-trips total):
// GOOD: pipeline all Get calls, then collect all results
_, err = s.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
// Phase 1: issue ALL Get calls. None block yet.
futures := make([]fdb.FutureByteSlice, len(pks))
for i, pk := range pks {
futures[i] = rt.Get(recordKey(s.ns, schema.Name, pk))
// rt.Get returns a Future immediately β it has NOT waited for a response.
}
// Phase 2: now block on each future in sequence.
for i, fut := range futures {
b, _ := fut.Get() // blocks until this specific response arrives
// But by the time we block on futures[1], the response for futures[1]
// through futures[N-1] may already be in flight or arrived.
...
}
})
Phase 1 sends N requests in rapid succession without waiting for any response.
The FDB client library batches these into its network buffer and sends them.
The storage servers process all N in parallel. By the time we call Get() on
the first future, many responses have already arrived.
Network diagram:
Without pipelining: With pipelining:
β Get(pk1) β Get(pk1), Get(pk2), Get(pk3), ... (burst)
β reply β reply(pk1)
β Get(pk2) β reply(pk2)
β reply β reply(pk3)
... (collect all)
Total: N round-trips Total: ~1 round-trip worth of latency
Why this works in FDBβs model:
FDB transactions are snapshot reads. All Get calls within one
ReadTransact closure see the same version of the database. There are no
cross-key dependencies or ordering constraints on reads within a transaction.
So the FDB client can send all reads in any order, and they will all return
consistent (same read version) values.
This is the same principle as HTTP pipelining, REDIS pipelining, or database query batching β but with a guarantee that all reads are from the same snapshot.
6. The encodeInt64 Sign-Bit Trick (Derived from First Principles)
When indexing a numeric field (e.g., age: 25), we want the index to sort
numerically. Bytes sort lexicographically. How do we make byte-sort match
numeric sort for 64-bit signed integers?
Step 1: Start with what we have.
A 64-bit signed integer in twoβs complement:
0 = 0000 0000 0000 0000 0000 0000 0000 0000 (0x0000_0000_0000_0000)
1 = 0000 0000 0000 0000 0000 0000 0000 0001 (0x0000_0000_0000_0001)
-1 = 1111 1111 1111 1111 1111 1111 1111 1111 (0xFFFF_FFFF_FFFF_FFFF)
-2 = 1111 1111 1111 1111 1111 1111 1111 1110 (0xFFFF_FFFF_FFFF_FFFE)
MIN = 1000 0000 0000 0000 0000 0000 0000 0000 (0x8000_0000_0000_0000)
MAX = 0111 1111 1111 1111 1111 1111 1111 1111 (0x7FFF_FFFF_FFFF_FFFF)
Big-endian byte-sort order: MIN, -2, -1, 0, 1, MAX (MIN starts with 0x80, which is 128, larger than 0x00 which is 0). So big-endian twoβs complement does NOT sort correctly across the sign boundary.
Step 2: What would correct order look like?
We want the sorted sequence: β¦, -2, -1, 0, 1, β¦, MAX. In bytes, each element should be strictly less than the next. If we could assign distinct 64-bit unsigned values to each signed value such that the unsigned order matches the signed order, weβd be done.
Step 3: The offset encoding idea.
One option: add 2^63 to shift the range. Then:
-2^63 + 2^63 = 0
-1 + 2^63 = 2^63 - 1
0 + 2^63 = 2^63
1 + 2^63 = 2^63 + 1
2^63-1 + 2^63 = 2^64 - 1
These are now unsigned values in the range [0, 2^64-1], in the same order as the original signed values. And they sort correctly in big-endian bytes!
Step 4: Addition vs. XOR.
Adding 2^63 is the same as flipping just the sign bit (bit 63). This is because adding 2^63 to a 64-bit value either sets bit 63 (if it was 0) or clears bit 63 and carries into bit 64 (which wraps around, but 2βs complement overflow is the same as XOR on individual bits).
More precisely: x + 2^63 = x XOR 2^63 for all x in the range
[-2^63, 2^63-1]. This is because:
- If x β₯ 0: bit 63 of x is 0; adding 2^63 sets it to 1. XOR with 1<<63 also sets bit 63. Same result.
- If x < 0: bit 63 of x is 1; adding 2^63 causes a carry into bit 64, which is discarded in 64-bit arithmetic, leaving bit 63 as 0. XOR with 1<<63 also clears bit 63. Same result.
So the implementation is just:
func encodeInt64(x int64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(x)^(1<<63))
return b
}
Verification:
-2 β uint64(-2) = 0xFFFFFFFFFFFFFFFE β XOR 0x8000... β 0x7FFFFFFFFFFFFFFE
-1 β 0xFFFFFFFFFFFFFFFF β XOR β 0x7FFFFFFFFFFFFFFF
0 β 0x0000000000000000 β XOR β 0x8000000000000000
1 β 0x0000000000000001 β XOR β 0x8000000000000001
2 β 0x0000000000000002 β XOR β 0x8000000000000002
Sorted by bytes: 0x7FFFFFFFFFFFFFE, 0x7FFFFFFFFFFFFFFF, 0x8000000000000000, 0x8000000000000001, 0x8000000000000002 β which is the same order as -2, -1, 0, 1, 2. Correct.
This is the standard encoding used by:
- FDBβs official Tuple layer (negative integers)
- Apache HBase row key encoding
- Google Cloud Bigtable ordered keys
- Apache Flink and Spark for sorted key encodings
7. Index Consistency: The Core Guarantee
Letβs state it precisely: at any point in time, for every record with field
F=V and PK=P, there exists an index entry at (F, V, P). And there are no
index entries without corresponding records.
Formally:
forall records: indexEntries(record) β presentIndexKeysforall indexKey: correspondingRecord exists
Both directions. Our PutRecord maintains this invariant within one
transaction:
- Remove old index entries β breaks βno stale entriesβ for old value
- Write new record β old record value is overwritten
- Write new index entries β restores consistency
Between steps 1 and 3, within the transaction, the invariant is temporarily broken. But no other transaction sees this intermediate state β FDBβs isolation means only the final committed state is visible.
What happens if the invariant is violated?
A phantom index entry (index says Alice is in Paris, record says Tokyo):
LookupByIndex(city="Paris")returns Alice- Fetching Aliceβs record shows city=Tokyo
- Application logic sees inconsistent data
This is precisely the class of bugs that a record layer is designed to prevent.
8. The Apple Story: fdb-record-layer and CloudKit
Appleβs CloudKit powers iCloud sync for iOS and macOS apps. At its peak it handles hundreds of millions of devices syncing calendar events, notes, photos metadata, and app-specific data. This is the production scale at which fdb-record-layer operates.
Why FDB?
Before FDB, Apple had a custom distributed database for CloudKit. It had the usual problems: hard to scale, hard to operate, hard to maintain consistency under failures. FDB was acquired in 2015. Over the next few years Apple rebuilt CloudKitβs storage engine on FDB.
Why a record layer on top?
FDB is a KV store. CloudKit stores typed records (calendar events have a
start_date, end_date, title; notes have body, attachments). You
cannot just say βfind all events starting tomorrowβ to an FDB cluster β you
need to translate that query into a range scan over an encoded index.
fdb-record-layer provides:
- Protobuf-typed records (schema validation, efficient encoding)
- Value indexes, rank indexes, text indexes, aggregate indexes
- A query planner that selects the best index for a given predicate
- Change feed via versionstamped index keys (see GUIDE.md chapter on versionstamps)
- Schema evolution (add fields with defaults, deprecate fields, change types safely)
Our implementation omits the Protobuf (replaced with map[string]any) and
the query planner (you call LookupByIndex directly). But the index key
layout and the PutRecord read-modify-write pattern are the same.
9. Real-World Analogues
MySQL InnoDB Secondary Indexes
In InnoDB, a secondary index B-tree contains:
(secondary_key_value, primary_key_value) β ""
Identical to our (field, encodedValue, pk) β "". To look up a row by
a secondary key:
- Range-scan the secondary index to get PKs.
- Look up each PK in the clustered index (the primary B-tree).
This two-step process is called a βdouble-lookupβ or βbookmark lookupβ
in MySQL docs. Our LookupByIndex does exactly this.
The reason InnoDB stores the PK (not the heap pointer) in secondary indexes: when a row is updated and moves in the clustered index, secondary indexes donβt need to be updated (they still point to the same PK value, which now lives in a different B-tree page but is found by the clustered index scan).
PostgreSQL GIN / B-Tree Indexes
PostgreSQL secondary indexes store (index_value β ctid) where ctid is the
physical page+slot address. This is faster to look up (one step, not two)
but requires updating ALL indexes when a row moves (e.g., after VACUUM).
PostgreSQL recently added βindex-only scansβ where the full row is stored in the index (similar to InnoDBβs covering indexes), trading space for read speed.
MongoDB Indexes
MongoDB maintains indexes as separate B-trees:
{ city: "Paris" } β [ ObjectId("alice"), ObjectId("carol") ]
MongoDBβs analog of LookupByIndex is called a βsecondary index scan +
fetchβ. The MongoDB Document Layer (which stored MongoDB data in FDB) used
exactly our two-subspace approach β one FDB range for documents, one for
each index.
10. Exercises
Exercise 1 β COUNT Aggregate Index
Add a special βcountβ index that maintains a per-schema counter:
ns + 0x02 + schemaName + 0x00 β msgpack(int64)
In PutRecord, if old record doesnβt exist, increment. In DeleteRecord,
decrement. Both inside the same transaction as the record+index update.
This gives you COUNT(*) in O(1) instead of a full table scan.
Exercise 2 β Range Index Query
Add LookupByIndexRange(schema, field, minVal, maxVal any) ([]Record, error).
Since the index is sorted by encoded field value, you can do:
begin = indexPrefix(ns, schema, field) + encodeValue(minVal)
end = indexPrefix(ns, schema, field) + encodeValue(maxVal) + 0x01
GetRange(begin, end)
This enables WHERE age >= 25 AND age < 35 as a single range scan.
Make sure encodeValue produces sort-preserving bytes for your supported
types (use encodeInt64 for integers).
Exercise 3 β Composite Indexes
Add support for multi-field indexes:
Schema{Name: "event", Indexes: [{"start", "end"}, {"city", "name"}]}
A composite index on (start, end) allows efficient queries like:
WHERE start >= monday AND end <= friday. The key would be:
ns + 0x01 + schema + 0x00 + "start,end" + 0x00 + encodeValue(start) + 0x00 + encodeValue(end) + 0x00 + pk
Exercise 4 β Covering Indexes
A covering index stores the full record at the index key (instead of just
""). Then LookupByIndex returns full records from the index scan alone,
with no secondary lookup:
index key β msgpack(record) (instead of β "")
Upside: LookupByIndex requires only one FDB range scan, no Get calls.
Downside: PutRecord must update index values when any indexed field changes.
Compare the trade-off with real-world covering indexes in MySQL and
PostgreSQL.
Exercise 5 β Versionstamp-Based Change Feed
Use FDBβs SetVersionstampedKey in PutRecord to write a change log entry:
ns + 0xFF + <10-byte versionstamp> β msgpack({schema, pk, change_type})
Build a SubscribeChanges(since versionstamp) (<-chan Change, error) function
that uses GetRange + Watch to stream changes in real time. This is a
simplified version of fdb-record-layerβs change feed.
11. Source Code Deep Dive
encoding.go β The Key Construction Functions
// tagRecords = 0x00, tagIndexes = 0x01
func recordKey(ns []byte, schema, pk string) fdb.Key {
return append(ns, append([]byte{tagRecords}, []byte(schema+"\x00"+pk)...)...)
}
func indexKey(ns []byte, schema, field string, val any, pk string) fdb.Key {
return append(ns, append([]byte{tagIndexes},
[]byte(schema+"\x00"+field+"\x00"+encodeIndexValue(val)+"\x00"+pk)...)...)
}
Why embed the separator \x00 as a Go string literal?
The \x00 byte is the separator between components. Using it directly in a string literal is readable and avoids a separate bytes.Join call. The key correctness requirement: no component value may contain \x00 unescaped. For string field values from map[string]any, callers must validate or escape input. The Tuple encoding in fdb-record-layer handles this by escaping \x00 inside strings as \x00\xFF.
encodeIndexValue β Dispatching on Type
func encodeIndexValue(val any) string {
switch v := val.(type) {
case int64:
return string(encodeInt64(v))
case float64:
// JSON numbers unmarshal as float64 in Go
return string(encodeInt64(int64(v)))
case string:
return v
default:
return fmt.Sprintf("%v", v)
}
}
The float64 case: In Goβs encoding/json, all JSON numbers unmarshal to float64 when the target type is any. This means {"age": 25} gives you age = float64(25). The encoding converts float64 to int64 first. This is lossy for non-integer floats β a production implementation would have a separate encodeFloat64 using the IEEE 754 bit pattern with a sign-flip (identical logic to encodeInt64, but on the math.Float64bits representation).
store.go β ScanRecords with Cursor Logic
func (s *Store) ScanRecords(schema string, limit int) ([]Record, error) {
kvs, _ := s.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(recordRange(s.ns, schema), fdb.RangeOptions{
Limit: limit,
}).GetSliceWithError()
})
var recs []Record
for _, kv := range kvs.([]fdb.KeyValue) {
var rec Record
msgpack.Unmarshal(kv.Value, &rec)
recs = append(recs, rec)
}
return recs, nil
}
fdb.RangeOptions{Limit: limit} instructs FDB to stop returning results after limit key-value pairs. This is pushed down to the storage server β the server stops scanning early, reducing both network bandwidth and server-side work.
The continuation problem: For a dataset with 1 million records, a single GetRange with no limit would transfer all 1M records in one response. This hits FDBβs per-transaction byte limit (~10 MB) and would transfer far more data than needed. A production scan should use a continuation token:
type Continuation struct {
LastPK string
}
func (s *Store) ScanRecordsPage(schema string, limit int, cont *Continuation) ([]Record, Continuation, error) {
begin := recordRange(s.ns, schema).Begin
if cont != nil {
// Start after the last-seen PK
begin = fdb.Key(append(recordKey(s.ns, schema, cont.LastPK), 0x01...))
}
// GetRange from begin to end of schema range, limit = limit
...
}
Each page call fetches limit records starting after the continuation. The caller loops, passing the continuation from each response into the next call.
index.go β LookupByIndex Pipelining in Detail
func (s *Store) LookupByIndex(schema Schema, field string, value any) ([]Record, error) {
prefix := indexPrefix(s.ns, schema.Name, field, value)
// Step 1: get all PKs from the index (one round-trip)
kvs, _ := s.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
return rt.GetRange(
fdb.KeyRange{Begin: prefix, End: fdb.Key(append(prefix, 0xFF))},
fdb.RangeOptions{},
).GetSliceWithError()
})
pks := make([]string, len(kvs))
for i, kv := range kvs {
pks[i] = string(extractPkFromIndexKey(kv.Key))
}
// Step 2: fetch all records by PK (pipelined, one round-trip)
recs := make([]Record, 0, len(pks))
s.fdb.ReadTransact(func(rt fdb.ReadTransaction) (interface{}, error) {
futures := make([]fdb.FutureByteSlice, len(pks))
for i, pk := range pks {
futures[i] = rt.Get(recordKey(s.ns, schema.Name, pk))
}
for _, fut := range futures {
b, _ := fut.Get()
var rec Record
msgpack.Unmarshal(b, &rec)
recs = append(recs, rec)
}
return nil, nil
})
return recs, nil
}
Two round-trips for any number of results: Step 1 is one GetRange β one round-trip. Step 2 issues N Get calls inside one transaction β they are pipelined to the storage servers, returning in approximately one round-trip (the latency of the slowest individual read, not N Γ single-read latency).
Why two separate transactions?
The index scan (step 1) and the record fetches (step 2) could be in one ReadTransact closure β they would both see the same read version. We separate them here for clarity. In production, combining them into one transaction is slightly more efficient: one beginRead call, one read version negotiation, one transaction context.
12. Production Considerations
12.1 Write Amplification Under Index Updates
Every PutRecord that changes an indexed field performs:
- 1 read (old record)
- 1 clear (old index entry)
- 1 set (new record)
- 1 set (new index entry)
For a schema with K indexes and a write that changes all K indexed fields:
- K reads (old index values embedded in old record, but we need the old record regardless)
- K clears (old index entries)
- 1 set (new record value)
- K sets (new index entries)
Total mutations: K clears + K+1 sets = 2K+1 write operations per update. For a schema with 5 indexes, every update writes 11 keys. This is standard for indexed databases β MySQL and PostgreSQL have the same write amplification for secondary indexes. FDBβs commit pipeline absorbs this efficiently because all K sets and K clears go into one transaction batch.
Mitigation: Only maintain indexes on fields that are actually queried. Donβt add indexes speculatively.
12.2 Online Index Building
Adding a new index to a schema that already has millions of records requires backfilling: writing index entries for every existing record. This must be done without taking the database offline.
The safe approach:
- Start writing new index entries in
PutRecordimmediately (for new and updated records). - In a background worker, page through all existing records (using the continuation pattern) and write their index entries.
- Once the background worker reaches the βfrontierβ (current write position), the index is consistent.
During step 2, queries against the new index may return incomplete results (missing records that havenβt been backfilled yet). This is acceptable if the application can tolerate βeventually consistentβ index builds. FDBβs versionstamp mechanism can track the backfill position and indicate to callers which index entries are βsafeβ (backfilled) vs βtentative.β
12.3 N+1 Read Problem
LookupByIndex with N results does:
- 1 index range scan
- N record reads (pipelined)
If the calling code then calls LookupByIndex for each result (e.g., βfind all events in Paris, then for each event, find all attendeesβ), you get M index scans + MΓN record reads. This is the N+1 problem from ORM land, applied to FDB.
Solution: Batch the secondary lookups. Collect all PKs from all index scans, then issue one pipelined batch of record reads. FDBβs pipelining makes this efficient even when batching hundreds of PKs.
13. Interview Questions β Secondary Indexes and Record Layers
Q: Why does PutRecord need to read the old record before writing the new one?
To maintain index consistency. If a recordβs indexed field value changes (e.g., city: βParisβ β βTokyoβ), the old index entry (city=Paris,pk=alice) must be deleted and a new entry (city=Tokyo,pk=alice) must be created β in the same transaction as the record write. To know the old index value, we need the old record. Without reading the old record, we canβt know which index keys to clear, so stale index entries would accumulate, pointing to records with wrong field values.
Q: How does LookupByIndex achieve O(2) round-trips regardless of result count?
The index range scan (step 1) is one GetRange that returns all PKs for the queried field value β one round-trip. The record reads (step 2) are issued as N rt.Get calls inside one ReadTransact closure. The FDB client library sends all N requests before waiting for any response (pipelining). The storage servers process them in parallel. By the time the first response arrives, most others are already in flight or returned. The effective latency is one round-trip, not N.
Q: What is the encodeInt64 function doing, and why is it necessary?
FDB keys are sorted lexicographically by bytes. For integer field values to sort correctly as numbers (β1 < 0 < 1 < 2), the byte encoding must be sort-preserving. Twoβs complement big-endian is almost right, but fails at the sign boundary: β1 (0xFFFFβ¦) sorts after MAX_INT (0x7FFFβ¦) in byte order. Flipping the sign bit (XOR with 0x8000_0000_0000_0000) shifts the entire integer range to unsigned, making byte order match signed integer order. The result: every negative numberβs encoding is less than every non-negative numberβs encoding, and within each sign region, the encoding preserves order.
Q: How would you implement multi-tenant isolation so that one tenantβs records are never visible to another?
Add a tenant identifier to the namespace prefix: ns = globalPrefix + tenantId. All record and index keys for that tenant are prefixed with their unique tenant bytes. A range scan by tenant A (GetRange(ns_A, ns_A + 0xFF)) only returns keys in tenant Aβs namespace. The FDB cluster enforces this: there are no keys shared between tenant namespaces, so no data can leak across tenants. Additionally, for strict isolation, use FDBβs tenant feature (available in 7.x) which restricts a transaction to a specific tenant prefix at the FDB server level.
Build LevelDB from Scratch β Go
This series of eight labs walks you through building a production-quality key-value storage engine from first principles, one subsystem at a time. Each lab compiles and runs independently; later labs import earlier ones so you always have a working engine at the end of every step.
| Lab | Topic |
|---|---|
| Lab 01 | Write-Ahead Log (WAL) |
| Lab 02 | MemTable + Internal Keys |
| Lab 03 | Write Path: WAL β MemTable + Crash Recovery |
| Lab 04 | SSTable (Sorted String Table) |
| Lab 05 | MemTable Flush to L0 |
| Lab 06 | Compaction and Merged Iterator |
| Lab 07 | Snapshots, Compatibility Layer, and Benchmarks |
| Lab 08 | LevelDB from Scratch (Capstone) |
Big picture
WAL β MemTable (SkipList) β flush β L0 SSTables
β
compaction
β
L1 SSTables
Start with Lab 01 β Write-Ahead Log.
Lab 01 β Write-Ahead Log (WAL)
- Concept: Why a write-ahead log?
- Why append-only?
- What
Syncmeans- The complete
write()path: userspace β persistent storage fsyncvsfdatasyncvssync_file_range- Dirty page writeback: the kernelβs async path
O_DIRECT: bypassing the page cacheO_DSYNC/O_SYNC: per-write durability- Drive write cache: the last hiding place
- macOS:
F_FULLFSYNC sync_file_range: pipelined WAL writesio_uring: truly async file I/O without blocking syscallsepoll/select/pollβ why they do not apply to file I/O
- The complete
- Partial writes and tail corruption
- File format
- Implementation notes
- Running the tests
- Running the demo
- FoundationDB parallel
Concept: Why a write-ahead log?
Any storage engine faces one fundamental problem: memory is volatile and disk writes are not atomic. If the process crashes mid-write, the on-disk state could be partially updated β half a key written, a stale value left in place, or an index that disagrees with the data it points to.
The classic solution is a write-ahead log (WAL): a simple append-only file where every mutation is recorded before it modifies any other data structure. On crash, the WAL is replayed from beginning to end, reconstructing the in-memory state exactly as it was at the moment of the crash.
Normal write: Crash at β : Crash at β‘:
1. append to WAL β WAL record written WAL record written
2. update memory β‘ memory update lost memory updated
3. acknowledge caller never got OK replay restores memory
Because the write is only acknowledged to the caller after the WAL record is durably on disk, the promise is: any acknowledged write survives a crash.
Why append-only?
Random writes on spinning disk require a physical seek β moving the read head to the right track and waiting for the right sector to rotate under it. A sequential append never seeks; it always writes to the end. Even on SSDs, sequential writes are preferred: the FTL (Flash Translation Layer) batches and aligns sequential writes more efficiently, reducing write amplification inside the drive. An append-only log extracts the maximum possible throughput from the underlying storage hardware.
HDD mechanics
IOPS (Input/Output Operations Per Second) measures how many discrete read-or-write operations a storage device can sustain per second. One IOPS covers the full round-trip from when the OS submits the request until the drive acknowledges completion. Storage benchmarks always quote a 4 KB block size because 4 KB is the smallest addressable unit on most filesystems and matches the CPU MMUβs page size.
DMA (Direct Memory Access) is a hardware capability that lets a storage controller transfer data directly between its internal buffers and host RAM without occupying the CPU. The CPU writes a DMA descriptor (source address, destination address, byte count) to the controllerβs register, then goes on to other work. The controllerβs DMA engine moves the bytes autonomously and raises a CPU interrupt when done. Without DMA, every byte would require a CPU load + CPU store β hundreds of millions of wasted cycles per second at modern storage speeds.
A spinning disk at 7,200 RPM completes one revolution in 8.3 ms. Every random write requires three sequential phases before the first byte hits magnetic media:
| Phase | What happens | Typical cost |
|---|---|---|
| Seek | Actuator arm moves to the correct cylinder | 3β9 ms (avg ~5 ms) |
| Rotational latency | Wait for target sector to rotate under head | 0β8.3 ms (avg ~4 ms) |
| Transfer | DMA at ~150β250 MB/s | ~0.03 ms for 4 KB |
Random 4 KB write: ~9 ms β ~110 IOPS. Sequential 4 KB write (head already at end): ~0.03 ms β ~33,000 IOPS.
The ratio is roughly 300Γ. A WAL that forces sequential access converts a seek-bound device into a throughput-bound device for the write path.
SSD / NVMe mechanics
SSDs have no mechanical actuator arm, so seek time is near zero (~50 Β΅s vs. ~5 ms for HDD). But flash storage introduces fundamentally different constraints β rooted in the physics of how NAND cells store charge.
NAND flash: floating-gate transistors
A NAND flash cell is a floating-gate transistor β a standard MOSFET with an extra layer of polysilicon (the βfloating gateβ) sandwiched between two thin insulating oxide layers. The floating gate is electrically isolated: charge placed on it has nowhere to go and stays there for years.
Control gate (word line β driven by the SSD controller)
β
ββββββββββββββββββββββββββββββ β interpoly dielectric
ββββββββ β ββββββββββββββββββ β floating gate (stores charge)
ββββββββββββββββββββββββββββββ β tunnel oxide (~8 nm β key constraint)
β
ββ Source βββ¬ββ Drain ββββββ β silicon channel / bit line
- Programming (writing a 0): the controller applies ~20 V to the control gate. Quantum tunneling forces electrons from the channel through the thin tunnel oxide onto the floating gate. The trapped electrons lower the transistorβs threshold voltage β the sense amplifier reads the cell as logic 0.
- Erasing (resetting to 1): the controller applies a large negative voltage to the control gate (or positive to the substrate). Electrons tunnel back off the floating gate. The threshold voltage rises β the cell reads as 1.
- Reading: the controller applies a moderate voltage between 0 V and the programmed threshold. If the cell conducts (floating gate uncharged) β 1. If it does not (floating gate charged, threshold lowered below sense voltage) β 0.
The critical asymmetry that drives all SSD design decisions: programming is fine-grained (one page at a time), but erasing is coarse-grained (one entire block at once) β because the erase circuit applies voltage uniformly across all cells in a block via their shared substrate connection.
SLC, MLC, TLC, QLC: bits per cell
The floating gateβs charge exists on a continuous voltage spectrum. By dividing that spectrum into more discrete voltage windows, you can store more bits per cell:
SLC (1 bit): |ββββ 1 βββββ|ββββ 0 βββββ|
uncharged charged
(wide margin β tolerates oxide degradation)
MLC (2 bits): |ββ 11 ββ|ββ 10 ββ|ββ 01 ββ|ββ 00 ββ|
4 voltage windows
TLC (3 bits): |β111β|β110β|β101β|β100β|β011β|β010β|β001β|β000β|
8 voltage windows (~100 mV margin between each)
QLC (4 bits): 16 voltage windows (~30 mV margin β extreme sensitivity)
| Type | Full name | Bits/cell | Voltage windows | P/E endurance | Notes |
|---|---|---|---|---|---|
| SLC | Single-Level Cell | 1 | 2 | 50,000β100,000 | Enterprise WAL / cache drives |
| MLC | Multi-Level Cell | 2 | 4 | 3,000β10,000 | High-endurance enterprise |
| TLC | Triple-Level Cell | 3 | 8 | 1,000β3,000 | Most consumer SSDs today |
| QLC | Quad-Level Cell | 4 | 16 | 100β1,000 | High-capacity, read-heavy use |
More bits per cell = smaller voltage margins. Each P/E cycle slightly degrades the tunnel oxide by trapping a residual charge, shifting all threshold voltages slightly. With 8 windows spaced ~100 mV apart (TLC), a shift of just 50 mV can move a cell from window 3 into window 4 β a read error. SLCβs two wide windows tolerate far more oxide degradation before read failures occur. Selecting drive type is not optional for production WAL servers: a database WAL on a QLC SSD may wear out in months under heavy write load.
P/E cycles: program and erase at the physics level
A P/E cycle (Program/Erase cycle) is one complete round of:
- Program: force electrons onto the floating gate via Fowler-Nordheim quantum tunneling β electrons pass through the ~8 nm tunnel oxide under a strong electric field (~10 MV/cm).
- Erase: remove those electrons by reversing the field direction.
Each cycle injects a small amount of charge into the tunnel oxide itself (not the floating gate). This oxide-trapped charge gradually raises the tunnel barrier, requiring ever-higher programming voltages and eventually making cells permanently stuck in one state. After TLCβs rated ~1,000β3,000 P/E cycles, blocks are retired β the FTL marks them bad and stops allocating to them.
The physical hierarchy: Die β Plane β Block β Page
NAND chips are organised in a strict four-level hierarchy that determines which operations can run in parallel and at what granularity:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Die (one physical silicon chip; an M.2 drive has 4β16 dies) β
β β
β ββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββ β
β β Plane 0 β β Plane 1 β β
β β ββββββββββββββββββββββββ β β ββββββββββββββββββββββββ β β
β β β Block 0 (256 KBβ4 MBβ β β β Block 0 β β β
β β β page 0 (4β16 KB) β β β β page 0 β β β
β β β page 1 β β β β page 1 β β β
β β β page 2 β¦ β β β β β¦ β β β
β β ββββββββββββββββββββββββ€ β β ββββββββββββββββββββββββ€ β β
β β β Block 1 β β β β Block 1 β β β
β β β β¦ β β β β β¦ β β β
β β ββββββββββββββββββββββββ β β ββββββββββββββββββββββββ β β
β ββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Die: one physical silicon chip. A modern M.2 NVMe drive contains 4β16 dies. Multiple dies can operate in parallel (die interleaving) β the controller submits a page write to die 0 while die 1 is still programming a previous request (~100β300 Β΅s), hiding the programming latency behind parallelism.
- Plane: each die has 2β4 planes, each with its own page register and sense amplifiers. Planes within one die can execute one operation simultaneously β a 2-die Γ 2-plane drive has 4 independent NAND operation slots.
- Block: the erase unit β 256 KB to 4 MB of pages that share an erase circuit. Think of a block as a hotel floor: you can furnish individual rooms (program pages), but to reset, you must vacate the entire floor at once (erase the whole block). Pages within a block can only be programmed once per erase cycle, in order from page 0 upward.
- Page: the read and write unit β 4β16 KB. The smallest granularity at which data can be programmed into NAND or read out.
The fundamental constraint that follows: you cannot overwrite an existing page in place. To update data at logical address X, the controller must write it to a fresh page elsewhere and update a mapping table. The old page becomes stale.
FTL: the Flash Translation Layer
The FTL is firmware running on the SSDβs controller chip β typically an ARM Cortex-R processor with 256 MBβ4 GB of its own DRAM for bookkeeping. The host OS presents read/write requests using Logical Block Addresses (LBAs) β a flat numbering of 512-byte or 4096-byte sectors from 0 to N. The FTL translates LBAs to physical NAND locations and manages three critical jobs:
1. Logical-to-Physical Mapping (L2P)
The FTL maintains an L2P mapping table in DRAM:
L2P table (one entry per LBA):
LBA 42 β Die 0, Plane 1, Block 17, Page 3 β current live copy
LBA 99 β Die 1, Plane 0, Block 29, Page 0
β¦
When the host overwrites LBA 42, the FTL:
- Allocates a fresh page (e.g., Die 0, Plane 1, Block 29, Page 1).
- Programs the new data there.
- Updates the L2P table: LBA 42 β Block 29, Page 1.
- Marks the old page (Block 17, Page 3) as stale β it holds outdated data and cannot be reused until its block is erased.
The L2P table is periodically checkpointed to reserved NAND blocks. If power is lost before a checkpoint, the FTL replays its own internal log on next power-on β the SSD controller runs a WAL for exactly the same reason our application does.
2. Garbage Collection (GC)
Over time, blocks accumulate stale pages. A block with only stale pages wastes space but canβt be reused until itβs erased. GC reclaims these blocks:
Victim block (Block 17): Step 1 β copy live pages out:
page 0 [stale] β write to fresh clean block
page 1 [live, LBA 5] βββ page in Block 31
page 2 [stale]
page 3 [live, LBA 8] βββ page in Block 31
page 4 [stale]
Step 2 β erase Block 17: Step 3 β Block 17 is now clean and reusable.
all 256 KB erased
This copy-then-erase is write amplification: the host wrote one new 4 KB page, but GC physically moved dozens of live pages internally to free up space.
WAF (Write Amplification Factor) measures how many bytes the drive actually writes per byte the host requested. WAF 10Γ means 10 physical writes per 1 logical write β consuming drive endurance 10Γ faster than necessary.
Under heavy random write load, GC runs continuously, competing with host I/O for NAND bandwidth. When all blocks hold at least some live data and GC must run before every host write, throughput collapses β the βwrite cliffβ. Sequential workloads that fill entire blocks in order leave no stale pages and impose almost zero GC pressure.
| Workload | Write Amplification Factor (WAF) |
|---|---|
| Pure random 4 KB overwrites | 10Γβ50Γ (heavy GC pressure) |
| Sequential full-block writes | ~1Γ (no copy-erase-write cycle) |
3. Wear Leveling
Without intervention, the FTLβs allocator would repeatedly reuse the same recently-emptied blocks while cold blocks (holding rarely-updated data) remain pristine. Those hot blocks exhaust their P/E cycles while most of the drive stays barely worn.
Wear leveling redistributes writes across all blocks:
- Dynamic wear leveling: new writes go to the least-worn block with free pages.
- Static wear leveling: periodically evict cold data off low-wear-count (pristine) blocks and relocate it to higher-wear blocks β freeing fresh cells for hot sequential writes.
A WAL β which writes sequentially and fills blocks in order β is the ideal workload for the FTL: GC pressure is minimal, pages fill blocks cleanly, and wear distributes naturally across the drive.
Consequences for WAL design:
- Drive endurance: TLC NAND has ~1,000β3,000 P/E cycles per cell. WAF 10Γ means the drive wears 10Γ faster than necessary. Choose SLC or MLC for write-intensive WAL workloads.
- Sustained throughput: GC competing with host writes causes the write cliff. Sequential workloads delay this dramatically.
- Wear leveling: the FTLβs algorithm works best when writes fill whole blocks in order, simplifying its bookkeeping.
Typical peak NVMe figures (consumer):
- Random 4 KB write: 300kβ700k IOPS.
- Sequential write: 3β7 GB/s.
- Sustained random write under GC load: 100kβ200k IOPS.
- Sustained sequential write: close to peak (GC almost absent).
O_APPEND atomicity guarantee (POSIX)
Our file is opened with O_APPEND. POSIX specifies:
The file offset shall be set to the end of the file prior to each write and no intervening file modification operation shall occur between changing the file offset and the write operation.
The kernel holds the inode lock between the implicit lseek(SEEK_END) and the
write(). Two concurrent processes both calling write() on the same
O_APPEND fd will not interleave within a single call β one atomically claims
its extent at EOF before the other begins.
// Both processes open the same WAL with O_APPEND.
int fd = open("wal.log", O_CREAT|O_RDWR|O_APPEND, 0644);
// Safe from two concurrent processes β the kernel serialises offset assignment.
write(fd, record_a, len_a); // Process A
write(fd, record_b, len_b); // Process B β appended after A, never overlapping
Limit for our design:
O_APPENDatomicity covers a singlewrite()call. OurAppenddoes two writes (header, then payload). If two goroutines shared the WAL and calledAppendconcurrently, the header of one could appear between the header and payload of the other. Our WAL is single-writer, so this is not an issue. Multi-writer WALs useflock/fcntllocks or a per-writer fd with a merge step. (flock(2)grants advisory exclusive or shared locks on an entire file;fcntl(2)withF_SETLK/F_SETLKWprovides POSIX record locks on byte ranges β both are advisory, meaning only co-operating processes that explicitly call the lock API are serialised.)
Filesystem journaling vs application WAL
Modern filesystems (ext4, XFS, APFS, ZFS, btrfs) maintain their own internal journals. These protect filesystem metadata β inode tables, directory entries, extent maps β not the applicationβs payload bytes.
| Layer | What it protects |
|---|---|
ext4 data=writeback | Metadata only; data may contain stale bytes after crash |
ext4 data=ordered (default) | Data flushed before journal commit; no content guarantee |
ext4 data=journal | Data and metadata both journalled; ~2Γ write amplification |
| Application WAL | Application-level logical mutations |
data=ordered prevents the βfile contains garbageβ bug (a new fileβs blocks are
committed before the directory entry that exposes them), but it provides no
guarantee about which application-level writes are logically durable. The
application still needs its own WAL to define that boundary.
ZFS is a special case: its copy-on-write B-tree model provides atomic block
writes and its ZIL (ZFS Intent Log) acts as a per-filesystem WAL. ZFS
never overwrites data in place β every write creates a new tree node and
atomically redirects the treeβs root pointer. The ZIL is a separate append-only
log device (ideally a low-latency SLC SSD or NVRAM SLOG β Separate LOG
Device) where synchronous writes are committed before ZFS applies them to the
main pool. When fsync arrives, ZFS flushes the ZIL. On recovery, the ZIL
is replayed to reconstruct the poolβs logical state. Most databases still keep
their own WAL for portability across filesystems.
What Sync means
Calling file.Write() on any OS copies bytes into the kernelβs page cache
β a region of RAM. The OS will flush dirty pages to disk eventually, but βeventuallyβ
is seconds later, not microseconds. If the power is cut before the flush, those
pages are lost.
fdatasync (or file.Sync() in Go) forces the kernel to flush all dirty pages
for that file to the physical storage medium before returning. It blocks until
the drive confirms the data is persistent. Every WAL append in our
implementation calls Sync, so each acknowledged write is truly durable.
The complete write() path: userspace β persistent storage
Understanding exactly where data lives at each stage is the key to knowing what can be lost in a crash.
Key abstractions in the Linux I/O stack
VFS (Virtual File System)
The VFS is the kernelβs abstraction layer over all filesystems. Every
filesystem (ext4, XFS, APFS, NFS, tmpfs) registers a file_operations struct
with the VFS containing function pointers for read, write, fsync, mmap, and so
on. When your Go program calls os.File.Write(), the kernel dispatches to the
registered handler:
// Simplified from fs/read_write.c
ssize_t vfs_write(struct file *file, const char __user *buf,
size_t count, loff_t *pos) {
// dispatches via the filesystem's registered .write_iter pointer:
return file->f_op->write_iter(&kiocb, &iter);
}
The VFS exists so the same write(2) syscall works correctly regardless of
which filesystem the file lives on β the caller never needs to know.
struct folio / struct page
The kernel page cache stores file data in 4 KB pages (matching the CPU MMUβs
page size). The newer struct folio groups multiple physically-contiguous pages
into one logical unit, reducing bookkeeping overhead for large I/Os. Each page
or folio tracks:
- The physical RAM frame it occupies.
PG_dirty: set when userspace has written to the page but it has not yet been flushed to disk.PG_uptodate: set when the page content reflects the on-disk state.
write() calls copy_from_user() to copy bytes from your processβs virtual
address space into the appropriate folio in the page cache, then sets PG_dirty.
The disk write happens later β asynchronously by the writeback thread, or
immediately if fdatasync is called.
struct bio and blk_mq
Once the filesystem decides to flush dirty pages, it does not call the device
driver directly. It constructs a struct bio and submits it to the block layer:
-
struct bio(Block I/O): the fundamental I/O request unit. It describes one logical transfer: which device, starting Logical Block Address (LBA β the flat numbering of 512-byte or 4096-byte sectors that the OS uses), and a scatter-gather list of (page, offset, length) tuples. Multiple small I/Os can be merged into onebioby the scheduler. -
blk_mq(Block Multi-Queue): the Linux block I/O scheduler (introduced in Linux 3.13 to replace the single-queue elevator). It maintains per-CPU software staging queues and per-hardware-queue dispatch queues, enabling parallel I/O submission on many-core machines. Available schedulers:mq-deadline: latency-bounded; prevents starvation. Good for HDDs.kyber: low-overhead; tuned for fast NVMe.bfq(Budget Fair Queueing): proportional I/O bandwidth fairness.none: no reordering; submit in arrival order. Best for NVMe.
SATA/AHCI: FIS and NCQ
SATA (Serial ATA) is the physical and electrical interface between the host and the drive. AHCI (Advanced Host Controller Interface) is the software interface β a standardised register layout that the kernelβs AHCI driver programs to submit commands over SATA.
A FIS (Frame Information Structure) is the packet format SATA uses to carry commands and data between host and drive β analogous to an Ethernet frame:
FIS type 0x27 β Register (Host to Device):
byte 0: FIS type (0x27)
byte 1: C=1 (command), port multiplier field
byte 2: ATA command register
0x35 = WRITE DMA EXT (write sectors)
0xEA = FLUSH CACHE EXT β issued by fdatasync
byte 3: Features
bytes 4β9: LBA (48-bit logical block address)
bytes 10β11: sector count
β¦
NCQ (Native Command Queuing): SATA allows up to 32 outstanding FIS commands in the driveβs internal queue simultaneously. The drive firmware reorders them for optimal access patterns (minimal seek distance for HDD; optimal NAND plane interleaving for SSD) and processes them out-of-submission order. The host submits up to 32 commands without waiting for each to complete β dramatically increasing throughput compared to single-command polling.
NVMe: SQE, CQE, doorbell registers, and MMIO
NVMe (Non-Volatile Memory Express) is the interface specification for PCIe- attached SSDs, designed from scratch to exploit parallelism at every level. Instead of AHCIβs single command register, NVMe uses ring buffers in host RAM that both the CPU and the SSD controller access via DMA:
Host RAM NVMe Controller (on the SSD PCB)
βββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββ
β Submission Queue (SQ) β β ARM DMA engine β
β [SQEβ][SQEβ][SQEβ]β¦ ββ PCIe DMA βΊβ reads SQEs from host RAM β
β head tail β β β programs NAND cells β
β (CPU writes SQEs here)β β β
βββββββββββββββββββββββββ β writes CQEs to host RAM β
βββββββββββββββββββββββββ β raises MSI-X interrupt β
β Completion Queue (CQ) ββ PCIe DMAββ (MSI-X: per-vector interrupts β
β [CQEβ][CQEβ][CQEβ]β¦ β β without sharing an IRQ line) β
β (CPU reads CQEs here) β ββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββ
-
SQE (Submission Queue Entry): a 64-byte command descriptor the CPU places in the SQ ring buffer. Fields include:
opcode(0x01 = write, 0x02 = read, 0x00 = flush),nsid(namespace ID),slba(starting LBA),nlb(number of logical blocks), andprp/sgl(host memory buffer address for DMA). -
CQE (Completion Queue Entry): a 16-byte result the controller places in the CQ after executing an SQE. Fields:
sq_head(how many SQEs the controller has consumed),status(0 = success, non-zero = error code),phasebit (toggles each ring wrap so software can tell new CQEs from old ones), andcid(Command ID, matching the original SQE). -
Doorbell register: after writing SQEs, the CPU signals the controller by writing the new SQ tail index to a doorbell register on the device.
-
MMIO (Memory-Mapped I/O): the NVMe controller exposes its registers (including doorbell registers) as a memory region via the PCIe BAR (Base Address Register) β a physical address range that the BIOS maps into the CPUβs address space. The CPU writes to the doorbell by executing a plain store instruction (
MOV [doorbell_addr], tail) β the PCIe interconnect routes the transaction to the controllerβs register file. No specialIN/OUTport instructions are needed; the address space itself is the communication channel.
NVMe supports up to 65,535 SQ/CQ queue pairs β one per CPU core β enabling fully lock-free parallel I/O. AHCIβs single-queue design is exactly the bottleneck NVMe was designed to replace.
User process
β write(fd, buf, n)
β β enters kernel via SYSCALL (x86-64) or SVC (ARM64) instruction
βΌ
VFS layer (Linux: fs/read_write.c β vfs_write β file->f_op->write_iter)
β dispatches to the filesystem's write implementation
βΌ
Filesystem (e.g. ext4_file_write_iter β generic_file_write_iter)
β copy_from_user() β copies bytes from user VA space into kernel page cache
β marks affected struct page / folio as PG_dirty
βΌ
Page cache (struct address_space, backed by XArray of struct folio)
β
β βββ write() RETURNS HERE βββββββββββββββββββββββββββββββββββββββββββββββββ
β bytes are in kernel DRAM; a power cut here loses them
β
β (from this point, writeback is ASYNCHRONOUS and unordered with writes)
βΌ
Writeback thread (kworker/flush-X:Y / bdi_writeback / wb_workfn)
β woken by: dirty_ratio threshold, dirty_expire_centisecs timer,
β or an explicit sync call
β iterates dirty folios β calls ->writepages() on the address_space
βΌ
Block layer (submit_bio β blk_mq)
β I/O scheduler (mq-deadline / kyber / bfq / none)
β merges adjacent requests, reorders for rotational efficiency
βΌ
Device driver
β SATA/AHCI: builds FIS (Frame Information Structure),
β queues into NCQ (Native Command Queuing, up to 32 slots)
β NVMe: writes SQE to Submission Queue,
β rings doorbell MMIO register β CQE arrives on completion
βΌ
Drive controller (volatile DRAM write buffer inside the drive)
β drive sends completion interrupt β DMA transfer complete
β host OS marks bio as completed
β
β βββ WITHOUT fdatasync, the OS considers the write "done" HERE βββββββββββ
β drive DRAM is still volatile; power cut = lost data
β
βΌ
Persistent storage (NAND flash / magnetic platter)
β drive programs NAND page (~100β300 Β΅s) or writes to platter sector
β
β βββ fdatasync guarantees we are at least HERE before returning ββββββββββ
fdatasync issues a FLUSH CACHE command that forces the drive to commit its
DRAM buffer to persistent media before responding.
- SATA: ATA
FLUSH CACHE EXT(opcode0xEA) - NVMe:
FLUSHadmin command (opcode0x00) - SCSI:
SYNCHRONIZE CACHE(10)
fsync vs fdatasync vs sync_file_range
| Syscall | Data flushed | Metadata flushed | Notes |
|---|---|---|---|
sync() | All dirty pages, system-wide | Yes | Shutdown / unmount |
fsync(fd) | All dirty pages for fd | Yes β mtime, ctime, size | Maximum safety |
fdatasync(fd) | Data pages for fd | Only if required to read data | WAL β slightly faster |
sync_file_range(fd, off, len, flags) | Specified byte range | No | Pipelined writes |
fdatasync is faster than fsync because it skips persisting the inodeβs
mtime and ctime. An inode (index node) is the kernelβs in-memory record
of a fileβs metadata β permissions, owner, size in bytes, and timestamps β
stored as a struct inode in kernel memory and persisted in a reserved area of
the filesystem. Every file has exactly one inode; directory entries hold the
filename and a pointer to the inode number. On a journalled filesystem (ext4,
XFS), flushing metadata requires an extra journal commit. For a WAL, you care
that payload bytes are durable β not that the fileβs modification timestamp is
persisted.
In C:
int fd = open("wal.log", O_CREAT|O_RDWR|O_APPEND, 0644);
write(fd, hdr, 8);
write(fd, data, data_len);
fdatasync(fd); // data-only flush β sufficient and faster than fsync
// fsync(fd); // use if you also need the file size update to survive a crash
In Rust:
#![allow(unused)]
fn main() {
use std::fs::File;
file.sync_data()?; // fdatasync β data only
file.sync_all()?; // fsync β data + metadata
}
In C++:
#include <unistd.h>
::fdatasync(fileno(fp)); // FILE* β fd
::fdatasync(raw_fd); // raw POSIX fd
Dirty page writeback: the kernelβs async path
Between write() and an explicit sync, dirty pages live in the page cache and
are flushed asynchronously by kernel worker threads, governed by sysctl tunables:
# Block writes once this % of RAM is dirty (hard throttle β write() stalls)
sysctl vm.dirty_ratio # default: 20
# Start background writeback at this % of RAM (soft limit β no blocking)
sysctl vm.dirty_background_ratio # default: 10
# Maximum age of a dirty page before forced writeback (centiseconds)
sysctl vm.dirty_expire_centisecs # default: 3000 (30 seconds)
# How often the background writeback thread wakes up
sysctl vm.dirty_writeback_centisecs # default: 500 (5 seconds)
A storage engine under heavy writes can hit vm.dirty_ratio and find write()
stalling β the kernel throttles the writing process until the writeback thread
catches up. This manifests as irregular latency spikes.
RocksDBβs production tuning guide recommends absolute byte values instead of ratios, so behaviour is independent of total RAM:
sysctl -w vm.dirty_bytes=209715200 # 200 MB hard throttle
sysctl -w vm.dirty_background_bytes=104857600 # 100 MB background start
Absolute limits prevent a 512 GB server with a 20% ratio from accumulating 100+ GB of dirty pages before stalling.
O_DIRECT: bypassing the page cache
O_DIRECT directs the kernel to DMA data between the user-space buffer and the
storage device, skipping the page cache entirely:
// Buffer, offset, and transfer size must ALL be aligned to the logical block
// size β query it with: ioctl(fd, BLKSSZGET, &lbs) (usually 512 B or 4096 B)
void *buf;
posix_memalign(&buf, 4096, ALIGN_UP(8 + data_len, 4096));
int fd = open("wal.log", O_CREAT|O_RDWR|O_DIRECT, 0644);
memcpy(buf, hdr, 8);
memcpy((char *)buf + 8, data, data_len);
// Transfer size and file offset must also be block-aligned
write(fd, buf, ALIGN_UP(8 + data_len, 4096));
// O_DIRECT skips the page cache but NOT the drive's volatile DRAM cache.
// fdatasync is still required for full durability.
fdatasync(fd);
Advantages:
- No double-copy: user memory β page cache β device becomes user memory β device.
- No page cache pollution: WAL bytes are write-once; caching them wastes RAM that the B-tree read path could use.
- Predictable latency: no interaction with dirty_ratio throttling.
PostgreSQL uses O_DIRECT for WAL on Linux. RocksDB has
use_direct_io_for_flush_and_compaction and use_direct_reads options.
macOS pitfall:
O_DIRECTis not supported on macOS. Usefcntl(fd, F_NOCACHE, 1)instead. Portable code needs an#ifdef __APPLE__guard.
O_DSYNC / O_SYNC: per-write durability
These flags make every write() behave as if immediately followed by
fdatasync (O_DSYNC) or fsync (O_SYNC):
// Every write() blocks until data is durable β no separate fdatasync needed
int fd = open("wal.log", O_CREAT|O_RDWR|O_APPEND|O_DSYNC, 0644);
write(fd, hdr, 8); // blocks until drive confirms persistence
write(fd, data, n); // blocks until drive confirms persistence
// no fdatasync call needed
Tradeoff: each write() now includes a full storage round-trip (~100 Β΅s for NVMe,
~10 ms for HDD). You cannot pipeline writes. For a WAL that calls fdatasync
once per record anyway, O_DSYNC with two writes is equivalent but less
efficient (two sync waits vs. one).
Drive write cache: the last hiding place
Even after write() returns and the kernel considers the I/O complete, bytes may
still be in the driveβs volatile DRAM write cache β a 32β256 MB buffer inside
the drive housing. The drive firmware uses it to:
- Acknowledge I/O to the host immediately (reducing host-visible latency).
- Coalesce and reorder writes internally for NAND wear leveling.
A power failure while bytes are in this buffer loses them permanently.
fdatasync / fsync issue a FLUSH CACHE command before returning (see
above), forcing the drive to drain its cache to persistent media.
Check whether your drive has a write cache:
hdparm -I /dev/sda | grep -i 'write cache'
# "Write cache" present β fdatasync issues FLUSH; critical for durability
Enterprise NVMe with Power Loss Protection (PLP) uses an on-board capacitor
to flush the DRAM cache to NAND on power loss β making fdatasyncβs FLUSH
optional. Consumer drives almost never have PLP.
macOS: F_FULLFSYNC
macOS fsync(2) is explicitly documented as advisory:
Note that while fsync() will flush all data from the host to the drive (i.e. the βpermanent storage deviceβ), the drive itself may not physically write the data to the platters for quite some time.
F_FULLFSYNC is the only macOS API that guarantees a drive-level FLUSH:
#include <fcntl.h>
// macOS-only: issues drive FLUSH CACHE; blocks until drive confirms persistence
if (fcntl(fd, F_FULLFSYNC) == -1) {
// F_FULLFSYNC fails on network filesystems (NFS, SMB) β fall back
fsync(fd);
}
Goβs os.File.Sync() calls F_FULLFSYNC on Darwin since Go 1.12 β our
implementation is correctly durable on macOS. SQLite has an explicit
#if defined(__APPLE__) block; PostgreSQL checks for F_FULLFSYNC at compile
time.
sync_file_range: pipelined WAL writes
sync_file_range lets you initiate writeback of a byte range asynchronously,
enabling overlap of disk I/O with CPU work β the core of pipelined WAL design:
// Write batch N
write(fd, batch_n, n_len);
off_t off_n = batch_n_offset;
// Initiate async writeback of batch N (returns immediately, no durability yet)
sync_file_range(fd, off_n, n_len, SYNC_FILE_RANGE_WRITE);
// While batch N flushes, build and write batch N+1
write(fd, batch_n1, n1_len);
// Block until batch N's data has actually reached the device
sync_file_range(fd, off_n, n_len,
SYNC_FILE_RANGE_WAIT_BEFORE |
SYNC_FILE_RANGE_WRITE |
SYNC_FILE_RANGE_WAIT_AFTER);
// fdatasync flushes remaining dirty pages + commits the file size to the inode
fdatasync(fd);
MySQL InnoDBβs redo log writer and PostgreSQLβs WAL writer both use this pipelining pattern. The async initiation overlaps disk I/O with CPU work, cutting effective latency by up to 50% on write-heavy workloads.
Linux-only:
sync_file_rangeis not in POSIX. macOS/BSD have no equivalent. Portable code falls back tofdatasync.
io_uring: truly async file I/O without blocking syscalls
The traditional fdatasync blocks the calling OS thread for ~100 Β΅s (NVMe) or
~10 ms (HDD). Linux 5.1 introduced io_uring β a shared ring-buffer interface
where userspace submits batches of operations and reaps completions without
blocking or entering the kernel per operation.
The ring-buffer model
io_uring allocates two lock-free ring buffers in a memory region shared
between userspace and the kernel (mapped into both address spaces via mmap).
No data is copied through the syscall boundary β both sides read and write the
same physical memory pages:
Userspace process Linux kernel
βββββββββββββββββββββββββββββββ
β SQE array (fixed-size) β β CPU writes command descriptors here
β [SQEβ][SQEβ][SQEβ]β¦ β
βββββββββββββββββββββββββββββββ€
β Submission Queue (SQ ring) β β CPU writes indices into SQE array,
β indices: [0][1][2]β¦ β advances SQ tail
β head (kernel) tail (CPU)β β β kernel advances SQ head as it consumes
βββββββββββββββββββββββββββββββ
kernel executes I/O asynchronously
(DMA, NAND programming, etc.)
βββββββββββββββββββββββββββββββ
β Completion Queue (CQ ring) β β kernel writes CQE results here,
β [CQEβ][CQEβ]β¦ β advances CQ tail
β head (CPU)β tail (kernel) β β CPU advances CQ head as it reads
βββββββββββββββββββββββββββββββ
- SQ (Submission Queue): a ring of indices into the SQE array. The CPU writes an SQE into the array and then pushes its index onto the SQ ring by advancing the tail. No syscall β just a memory write.
- CQ (Completion Queue): a ring of CQEs that the kernel fills when I/O
completes. The CPU polls the CQ tail or waits via
io_uring_enter()withIORING_ENTER_GETEVENTS. - SQE (Submission Queue Entry): a 64-byte descriptor encoding one I/O
operation. Key fields:
opcode(read, write, fsync, β¦),fd,addr(userspace buffer address),len,off(file offset),user_data(an opaque 64-bit value you choose β echoed back in the matching CQE). - CQE (Completion Queue Entry): a 16-byte result. Fields:
user_data(matches the originating SQE),res(return value β bytes transferred, or-errnoon error),flags. IORING_SETUP_SQPOLL: a dedicated kernel thread polls the SQ tail. The CPU can write SQEs and advance the tail β the kernel thread picks them up without a syscall, reducing hot-path overhead from ~1 Β΅s perio_uring_enter()to ~10 ns per SQE.IOSQE_IO_LINK: chains SQEs β SQE N+1 is not started until SQE N completes successfully. Guarantees ordering: header bytes before payload bytes before the fsync.IOSQE_IO_DRAIN: a write barrier β the marked SQE is not dispatched until all prior SQEs in the ring have completed. Used on thefsyncSQE to ensure data writes are durable before the sync is issued.
#include <liburing.h>
struct io_uring ring;
// IORING_SETUP_SQPOLL: kernel thread polls the SQ β zero syscalls for submission
io_uring_queue_init(256, &ring, IORING_SETUP_SQPOLL);
// Pre-register buffers to avoid per-operation VA mapping overhead
struct iovec iov[2] = {
{ .iov_base = hdr, .iov_len = 8 },
{ .iov_base = data, .iov_len = data_len },
};
io_uring_register_buffers(&ring, iov, 2);
// SQE 1: write header (linked β SQE 2 starts only after SQE 1 completes)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_write_fixed(sqe, fd, hdr, 8, file_offset, 0 /* buf index */);
sqe->flags |= IOSQE_IO_LINK;
sqe->user_data = TAG_WRITE_HDR;
// SQE 2: write payload (linked β SQE 3 starts only after SQE 2 completes)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write_fixed(sqe, fd, data, data_len, file_offset + 8, 1);
sqe->flags |= IOSQE_IO_LINK;
sqe->user_data = TAG_WRITE_DATA;
// SQE 3: fdatasync β drained (waits for ALL prior SQEs in the ring to finish)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_fsync(sqe, fd, IORING_FSYNC_DATASYNC);
sqe->flags |= IOSQE_IO_DRAIN; // write barrier
sqe->user_data = TAG_SYNC;
// Submit all three in one syscall (zero syscalls with SQPOLL if kernel is polling)
io_uring_submit(&ring);
// Thread is free to prepare the next record while I/O is in flight
// ... build next batch ...
// Reap completions when you actually need the result
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res < 0) { /* handle error: -errno */ }
io_uring_cqe_seen(&ring, cqe);
IOSQE_IO_LINK creates a dependency chain β each SQE starts only after the
previous one completes successfully. IOSQE_IO_DRAIN is a write barrier: the
fsync is not dispatched until all prior SQEs in the ring have completed.
With IORING_SETUP_SQPOLL, a kernel thread spins on the submission queue so
io_uring_submit requires no syscall at all on the hot path.
ScyllaDBβs entire storage engine is built on io_uring. RocksDB has
experimental io_uring support (use_io_uring = true).
epoll / select / poll β why they do not apply to file I/O
This confusion comes up often. epoll, select, and poll are I/O
readiness multiplexers: they tell a thread when a file descriptor has
transitioned from βnot readyβ to βready to read/write without blockingβ. They
work on objects backed by kernel queues that can genuinely stall β sockets,
pipes, eventfd, timerfd, signalfd.
Regular files are always ready from the VFS perspective. write() to a
regular file always succeeds immediately by copying into the page cache β it
never blocks waiting for βspace to become availableβ the way a TCP socket does.
Therefore:
epoll_ctl(epfd, EPOLL_CTL_ADD, regular_file_fd, ...)returnsEPERM.select()/poll()on a regular file always report it as both readable and writable, regardless of actual disk state.
For true async file I/O, the options are:
| Mechanism | Platform | Notes |
|---|---|---|
io_uring | Linux 5.1+ | Recommended; zero-syscall hot path, full async |
POSIX AIO (aio_write / aio_fsync) | POSIX | Often implemented with threads internally |
libaio (kernel AIO) | Linux | Low-level; used by PostgreSQL, MySQL for O_DIRECT |
kqueue + AIO | BSD/macOS | Platform-specific |
| Thread pool | All | Go runtimeβs approach β parks goroutine on OS thread |
POSIX AIO (<aio.h>): the standard async I/O API. You submit an aio_write()
with a struct aiocb describing the operation, then poll with aio_error() or
wait with aio_suspend(). On Linux, glibc implements POSIX AIO using an
internal thread pool β it is not truly kernel-level async. On FreeBSD and
macOS, kqueue notifications enable genuine kernel-level async completion.
libaio (Linux kernel AIO, <libaio.h>): a Linux-specific low-level API
that submits I/O directly to the kernelβs async submission path. Unlike POSIX
AIO, it works correctly with O_DIRECT (bypassing the page cache) and uses no
hidden threads. PostgreSQLβs WAL writer and MySQL InnoDBβs redo log use
libaio when O_DIRECT is enabled. It has been largely superseded by
io_uring, which is safer and more featureful.
kqueue (BSD/macOS): a general-purpose kernel event notification interface
β the BSD equivalent of Linux epoll. Combined with the EVFILT_AIO filter
it delivers a notification when a struct aiocb AIO operation completes,
enabling event-loop-style async file I/O on macOS without hidden threads.
Goβs os.File.Write issues a blocking write() syscall. The Go runtime
detects the block, parks the goroutine, and hands the OS thread to another
goroutine. This is not zero-copy async I/O β one OS thread is consumed per
concurrently-blocked file write. For a single-writer WAL this is fine; for
high-concurrency workloads, io_uring via cgo or a purpose-built library is
the right tool.
Partial writes and tail corruption
Even with Sync, a crash can leave a partial record at the end of the
WAL. Consider: the OS writes the length field (4 bytes), then crashes before
writing the payload. On restart, the WAL contains a valid header that promises
N bytes of payload, but the file ends after 4 bytes.
Our recovery logic detects this by checking that the file contains at least
headerSize + length bytes at every record boundary. A truncated record at
the end is treated as if the write never happened β the partially-written record
is silently discarded and recovery stops there. Records before the truncation
are still valid and are replayed normally.
CRC32 catches a different class of corruption: a record whose bytes were written completely but with a storage error (bit flip, bad sector). If the checksum does not match, recovery stops at that record β same policy as truncation.
Why only the tail can be corrupt
Every Append call ends with Sync() before returning. A successful return
means all bytes of that record are on persistent media. Therefore:
- Records 0 β¦ Nβ2: fully written and individually synced β permanently durable.
- Record Nβ1 (the last): may be truncated if the crash occurred during
Appendβ after some bytes were written but beforeSyncreturned. - Records N and beyond: do not exist in the file.
The only ambiguous region is the tail. This property justifies the βstop at first corrupt recordβ recovery policy β it is not conservative; it is exact.
Group commit exception: engines that batch multiple records per
fdatasync(PostgreSQL, MySQL, RocksDB) trade per-record durability for throughput. If a crash occurs during a batched sync, the entire batch may be absent β not just the last record. Recovery tracks a βknown-good LSNβ written at the end of each batch to know exactly how far to replay.LSN (Log Sequence Number): a monotonically increasing integer assigned to each WAL record or batch, acting as a cursor into the log. βReplay from LSN 47182β means skip all records with LSN β€ 47182 and apply all records with LSN > 47182. PostgreSQL encodes the LSN as a
(segment_file_number, byte_offset)pair (e.g.,0/3000028); MySQL InnoDB uses a single 64-bit byte offset from the start of the redo log. RocksDB calls the equivalent a sequence number embedded in every key.
What βsilent discardβ means for the caller
When the last record is partially written, the Append call either:
- Returned an error (process killed cleanly mid-write), or
- Never returned (power loss).
In neither case did the caller receive nil. The WALβs contract is:
Any
Appendthat returnednilis guaranteed to be recovered. AnyAppendthat did not returnnilmay or may not be present β callers must retry.
This is at-least-once delivery. Exactly-once semantics require sequence numbers at a higher layer (the memtable / MVCC layer deduplicates replays).
MVCC (Multi-Version Concurrency Control): a concurrency strategy where writers never overwrite existing data. Instead, they append a new version alongside the old one, stamped with a sequence number or transaction timestamp. Readers see the snapshot that was current at the start of their read, without blocking writers. For WAL recovery, MVCC matters because replaying the same record twice is safe: the second application of sequence number N is a no-op β the memtable already holds that version and ignores the duplicate.
CRC32 vs CRC32C vs stronger checksums
crc32.ChecksumIEEE uses the IEEE 802.3 polynomial. The probability of an
undetected random error is 1/(2^32) β 2.3 Γ 10β»ΒΉβ° per record β negligible for
a local WAL.
SIMD, SSE4.2, and AVX2: hardware-accelerated data processing
SIMD (Single Instruction, Multiple Data) is a CPU instruction class that applies one operation to multiple data elements in parallel using wide registers:
Scalar (one value per instruction):
ADD r1, r2 β one 32-bit addition
SSE2 (128-bit XMM registers β 4 Γ 32-bit lanes):
PADDD xmm0, xmm1 β four 32-bit additions in one clock cycle
AVX2 (256-bit YMM registers β 8 Γ 32-bit lanes):
VPADDD ymm0, ymm1 β eight 32-bit additions in one clock cycle
Modern x86 CPUs have 128-bit XMM registers (SSE family, all x86-64 since 2003) and 256-bit YMM registers (AVX/AVX2, Intel Haswell 2013+). ARM has 128-bit Neon registers plus a hardware CRC extension.
SSE4.2 (Intel Nehalem, 2008 β effectively all x86-64 hardware since ~2012)
added one purpose-built instruction: CRC32. It computes the CRC32C
(Castagnoli polynomial) checksum in hardware, one byte, two, four, or eight
bytes at a time:
uint32_t crc = 0xFFFFFFFF;
// _mm_crc32_u64: one instruction that processes 8 bytes, ~1 byte/cycle throughput
crc = _mm_crc32_u64(crc, *(uint64_t*)data);
// At 4 GHz: ~32 GB/s checksum throughput β 5β10Γ faster than a software table
ARM processors with the CRC extension (ARMv8.1-A, all Apple Silicon) provide
__crc32cd with equivalent throughput.
AVX2 (Intel Haswell, 2013) provides 256-bit integer SIMD. It does not directly accelerate CRC32 but enables vectorized hash functions like xxHash3 and BLAKE3 to process 32 bytes per instruction cycle β relevant when the checksum algorithm is hash-based rather than polynomial-based.
| Algorithm | Strength | HW acceleration | Used by |
|---|---|---|---|
| CRC32 (IEEE) | 32-bit | None on most CPUs | Our WAL |
| CRC32C (Castagnoli) | 32-bit, better polynomial | SSE4.2 _mm_crc32_u8, ARM CRC ext | LevelDB, RocksDB, NVMe, iSCSI |
| xxHash-64 | Not a CRC; good general hash | SIMD-accelerated | ClickHouse |
| XXH3-128 | 128-bit, very fast | AVX2 | Some modern engines |
CRC32C detects more error patterns and has native CPU instructions on all modern x86 (SSE4.2) and ARM (CRC extension) processors.
In C with SSE4.2:
#include <nmmintrin.h> // SSE4.2
// Process 8 bytes at a time for throughput; 1 byte for simplicity:
uint32_t crc32c(const uint8_t *data, size_t len) {
uint32_t crc = 0xFFFFFFFF;
while (len >= 8) {
crc = _mm_crc32_u64(crc, *(uint64_t *)data);
data += 8; len -= 8;
}
while (len--) crc = _mm_crc32_u8(crc, *data++);
return crc ^ 0xFFFFFFFF;
}
In Rust (auto-selects SSE4.2 or software fallback via crc32fast crate):
#![allow(unused)]
fn main() {
use crc32fast::Hasher;
let mut h = Hasher::new();
h.update(&data);
let checksum = h.finalize();
}
LevelDB and RocksDB switched from CRC32 (IEEE) to CRC32C for exactly these reasons. A production WAL should use CRC32C.
Sparse files and zero-padding: an edge case
On filesystems that support sparse files (ext4, XFS, APFS), a failed write may leave a hole β a region that reads as zeros but consumes no disk space. The recovery scanner would interpret those zeros as valid payload bytes, potentially computing a CRC over all-zero data.
If crc32(zeros, n) == stored_checksum (astronomically unlikely, but
theoretically possible), a corrupt or missing record could pass the CRC check.
Production WALs protect against this with a RecordType byte in the header:
ββββββββββββββ¬βββββββββββ¬βββββββββββββ¬ββββββββββββββββββββββ
β type (1 B) β len (4 B)β crc32 (4 B)β payload (len bytes) β
ββββββββββββββ΄βββββββββββ΄βββββββββββββ΄ββββββββββββββββββββββ
A zero type byte (0x00) is immediately recognisable as a hole rather than a
valid record, stopping recovery without any CRC computation. LevelDBβs log
format uses RecordType values kFullType, kFirstType, kMiddleType,
kLastType to support records that span 32 KB blocks.
WAL file after partial crash:
ββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββ¬βββββββββββββ
β record 0 (complete, CRC ok) β record 1 (complete) β trunc... β
ββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββ΄βββββββββββββ
β
recovery stops here,
records 0 and 1 replayed
File format
Each record occupies a contiguous run of bytes:
ββββββββββββββββββ¬βββββββββββββββββ¬βββββββββββββββββββββββ
β length (4 B) β CRC32 (4 B) β payload (length B) β
β uint32 LE β IEEE/LE β raw bytes β
ββββββββββββββββββ΄βββββββββββββββββ΄βββββββββββββββββββββββ
- length β byte count of the payload.
- CRC32 β IEEE checksum of the payload only (not the header). Detects silent data corruption during recovery.
- payload β arbitrary bytes; in later labs this encodes a sequence number, key type, key, and value.
Recovery reads records one at a time. If a header or payload is truncated, or the CRC32 does not match, recovery stops silently β exactly modelling a crash where the last write was incomplete.
Implementation notes
Why two separate Write calls in Append?
Append issues two write() syscalls β one for the 8-byte header and one for
the payload β rather than concatenating them into a single buffer first:
w.f.Write(hdr[:]) // fixed-size [8]byte allocated on the stack
w.f.Write(data) // caller-supplied []byte
Reason 1: zero allocation
Combining into a single slice requires:
buf := make([]byte, 8+len(data)) // heap allocation + GC pressure
copy(buf[:8], hdr[:])
copy(buf[8:], data)
w.f.Write(buf)
For a WAL called on every mutation this is a heap allocation on the critical
write path. Two separate writes eliminate it entirely. The Go escape analyser
confirms hdr stays on the stack when used as [8]byte β zero heap traffic.
Reason 2: no zero-copy join for stack array + slice
hdr is a [8]byte β a fixed-size array on the stack. data is a
[]byte β a heap-backed slice. The stack and heap regions are not adjacent in
memory. There is no way to present them as a single contiguous buffer to the OS
without allocating a new region and copying both into it.
The proper fix: scatter-gather I/O (writev)
POSIX provides writev(2) β a single syscall that atomically writes from
multiple non-contiguous buffers:
#include <sys/uio.h>
struct iovec iov[2];
iov[0].iov_base = hdr;
iov[0].iov_len = 8;
iov[1].iov_base = data;
iov[1].iov_len = data_len;
// One syscall; OS appends both buffers atomically w.r.t. O_APPEND offset
ssize_t written = writev(fd, iov, 2);
This achieves zero allocation (no copy needed) and halves the syscall count.
On a WAL writing millions of records per second, two syscalls vs. one is
measurable β each syscall involves a SYSCALL instruction (x86-64) or SVC
(ARM64), a ring-0 privilege-level switch, TLB management, and a return.
TLB (Translation Lookaside Buffer): a small hardware cache inside the CPUβs MMU (Memory Management Unit) that stores recently-used virtualβphysical address translations. Every syscall that passes a buffer pointer to the kernel requires the kernel to translate that virtual address to a physical one. If the mapping is not in the TLB, the MMU walks the multi-level page tables in RAM β ~20β100 extra cycles. Pre-registering buffers with
io_uring(io_uring_register_buffers) pins their physical addresses in the kernelβs DMA mapping, eliminating this per-submission TLB overhead.
Go exposes writev indirectly through net.Buffers:
bufs := net.Buffers{hdr[:], data}
bufs.WriteTo(w.f) // calls writev(2) on Linux/macOS β zero alloc, one syscall
The tradeoff: dangling header on crash
A crash between the two writes leaves a valid 8-byte header followed by no
payload bytes. Recovery encounters this in Recover:
data := make([]byte, length)
if _, err := io.ReadFull(f, data); err != nil {
// io.ErrUnexpectedEOF β truncated payload
break // silently discard and stop
}
The partial record is discarded. This is the correct, intended behaviour.
Importantly, writev does not eliminate this risk. A single writev call
is not crash-atomic β the kernel can flush the first iov (the header) to disk
before the second iov (the payload), leaving the same dangling-header state.
The only true atomic record boundary is the fdatasync at the end: once it
returns, the complete header+payload record is durable.
The two-write vs. one-write vs. writev distinction affects syscall overhead
only β not crash safety.
Why production engines still use two separate writes
RocksDB, LevelDB, and SQLite all use separate writes for header and payload:
writevrequires building aniovecarray β a small but real cost per call. Aniovecis a{ void *iov_base; size_t iov_len; }pair describing one buffer segment;writevaccepts an array of them and transfers all segments atomically in one syscall.- The
fdatasyncthat follows dominates latency by 3β4 orders of magnitude (~100 Β΅s NVMe, ~10 ms HDD vs. ~100 ns for an extrawrite()syscall that hits the page cache). - The dangling-header case is handled gracefully by recovery in all these engines anyway β the two-write approach has no correctness cost.
io_uring with linked SQEs (shown in the Sync section above) is the modern
answer for truly zero-overhead, batched, async record writes.
Self-framing: no delimiters needed
There are no separator bytes between records. The file is a dense stream:
[hdrβ][payloadβ][hdrβ][payloadβ][hdrβ][payloadβ]...
Recover can parse it because the format is length-prefixed (also called
TLV β Tag/Length/Value):
- Read 8 bytes β parse
lengthandchecksum. - Read exactly
lengthbytes β that is the complete payload. - Verify CRC32; if mismatch, stop.
- File cursor is now exactly at the start of the next record.
- Repeat from step 1.
The length field encodes the exact byte count of the payload that follows it β the reader always knows where one record ends and the next begins. No sentinel or delimiter byte is needed.
Why length-prefix beats delimiters
Delimiter-based framing (newlines, \0, magic sequences) has a fundamental
flaw: the delimiter must not appear in the payload. Either you forbid
certain byte values (restricting what can be stored), or you escape them (adding
complexity and variable overhead).
| Property | Length-prefix | Delimiter |
|---|---|---|
| Any byte value in payload | β | Requires escaping or forbidden bytes |
| O(1) skip to next record | β β add length to current offset | O(n) β scan until delimiter found |
| Detect truncation precisely | β β ReadFull fails exactly at boundary | Only at delimiter position |
| Parsing overhead | Zero β one integer read | Escape decode on every byte |
| Seek to arbitrary record N | O(N) β must scan from start | O(N) |
Who else uses this framing
Every major binary protocol and serialization format uses length-prefix framing:
| System | Frame header | Notes |
|---|---|---|
| Protocol Buffers | Varint field tag + varint length | Per-field framing within a message |
| gRPC | 1B compressed flag + 4B uint32 length | Per-message over HTTP/2 |
| LevelDB / RocksDB log | 7B: CRC32 + len + RecordType | 32 KB blocks; records span blocks |
| PostgreSQL WAL | XLogRecord.xl_tot_len (uint32) | Plus CRC32C over the full record |
| MySQL binlog | 19B event header with data_length | Includes timestamp, server_id |
| Kafka | 8B offset + 4B batch size | RecordBatch framing |
| Redis RDB | Type byte + length encoding | Special encoding for small integers |
| NVMe NVM Command Set | NLB field (number of logical blocks) | Hardware-level scatter-gather |
LevelDBβs block-based WAL framing
LevelDB adds a refinement on top of simple length-prefix: it divides the file
into fixed 32 KB blocks. A record that spans a block boundary is split into
kFirstType, kMiddleType, and kLastType fragments:
Block 0 (32 KB): Block 1 (32 KB):
ββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β hdr(kFullType) β record A β β hdr(kLastType) β record B... β
β hdr(kFirstType) β record B β β hdr(kFullType) β record C β
β ...continues... β β β β β
ββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
Advantages of the block layout:
- Recovery can seek to any 32 KB boundary and resume scanning β critical for log files that have grown to GB scale.
- A corrupt block can be skipped without losing records in adjacent blocks.
- Block-aligned I/O maps naturally to filesystem block size and SSD page size, reducing partial-block writes and improving read performance.
Our simple variable-length format is correct and sufficient for a lab. A production WAL should consider block alignment for recovery efficiency on large files.
Running the tests
cd leveldb
go test ./lab01/... -v -count=1
Expected output:
=== RUN TestAppendRecover
--- PASS: TestAppendRecover (0.00s)
=== RUN TestCRCDetects
--- PASS: TestCRCDetects (0.00s)
=== RUN TestEmptyFile
--- PASS: TestEmptyFile (0.00s)
PASS
ok github.com/10xdev/leveldb/lab01
Running the demo
go run ./lab01/demo
Expected output:
wrote: "apple"
wrote: "banana"
wrote: "cherry"
--- simulated crash ---
recovered 3 records:
[0] "apple"
[1] "banana"
[2] "cherry"
FoundationDB parallel
In FoundationDB, the transaction log plays this role automatically. Every committed transaction is durably written to multiple transaction log processes before the commit returns. You never interact with it directly β crash recovery is handled transparently by the cluster.
The key difference: FDBβs transaction log is replicated across at least two machines (configurable), so a single-machine failure does not lose any commits. Our WAL is a single-process approximation of the same guarantee.
The FDB storage server also maintains a WAL internally (the βmutation logβ) to protect in-memory B-tree mutations. The layering is: FDB transaction log (cross-machine durability) β storage server mutation log (single-machine durability) β B-tree pages (the permanent on-disk state). Our single WAL collapses all three into one.
Lab 02 β MemTable + Internal Keys
- Concept: The Skip List
- Why not an array, hash map, or balanced BST?
- Why balanced BSTs are hard to make concurrent
- Structure: a linked list with express lanes
- Probabilistic level assignment
- Search algorithm
- Insert algorithm
- Why two pointer writes per level is sufficient: proof by invariant
- Node layout in memory
- Memory fragmentation: what 40,000 nodes actually costs
- Cache behaviour
- Complexity summary
- Proving the sorted-array O(n) insert claim
- Proving O(log n) expected search cost: backwards analysis
- Making the skip list concurrent-safe
- Concept: Internal Keys and MVCC
- Iterator design: level-0 traversal
- What the MemTable wraps
- Running the tests
- Running the demo
- FoundationDB parallel
Concept: The Skip List
Why not an array, hash map, or balanced BST?
A storage engineβs in-memory write buffer needs four properties at the same time:
| Operation | Required? | Array | Hash map | Balanced BST | Skip list |
|---|---|---|---|---|---|
| Ordered iteration | Yes | O(n log n) sort | β (unordered) | O(n) | O(n) |
| Point lookup | Yes | O(n) | O(1) avg | O(log n) | O(log n) avg |
| Insert | Yes | O(n) shift | O(1) avg | O(log n) | O(log n) avg |
| Concurrent reads without locks | Desirable | Hard | Hard | Hard | Achievable |
The hash map disqualifies itself immediately: iteration returns keys in arbitrary order. An SSTable flush iterates the MemTable in sorted order to write keys sequentially β an unsorted structure would force a full sort on every flush.
The array can iterate in order, but inserting into a sorted array requires shifting all higher elements right β O(n) per insert on average. For a 4 MiB MemTable holding ~40,000 keys that is 20,000 moves per write β completely unacceptable.
That leaves the balanced BST and the skip list. Both achieve O(log n) for all required operations. Understanding why the skip list wins in practice requires understanding what balanced BSTs actually cost.
Why balanced BSTs are hard to make concurrent
Red-black tree: what rebalancing actually does
A red-black tree is a binary search tree where each node is coloured red or black and the following invariants are maintained after every insert/delete:
- Every node is red or black.
- The root is black.
- Every leaf (nil sentinel) is black.
- A red nodeβs children are both black (no two consecutive red nodes).
- All paths from any node to its descendant nil leaves pass through the same number of black nodes.
Invariant 5 guarantees that the longest path (alternating red/black) is at most
twice the shortest path (all black), bounding height at 2 logβ(n+1).
When you insert a new key, it arrives as a red node. If its parent is also red, invariant 4 is violated. The tree repairs itself via two operations:
Colour flip (recolouring): when the new nodeβs parent and uncle are both red, flip both to black and flip the grandparent to red. This may push the violation up the tree.
Before (violation: P and N are both red):
G (black)
/ \
P(r) U(r)
/
N(r)
After colour flip:
G (red) β now G may violate with its own parent
/ \
P(b) U(b)
/
N(r)
Rotation (restructuring): when recolouring alone cannot fix the violation, a
subtree rotation restructures three nodes. A left rotation on x:
x y
/ \ / \
A y β x C
/ \ / \
B C A B
A right rotation is the mirror image. The tree has four cases (left-left, left-right, right-right, right-left) each requiring one or two rotations.
The critical insight for concurrency: a single insert can trigger O(log n) recolouring steps and up to two rotations, modifying nodes at multiple levels of the tree. Making this atomic for concurrent readers is hard:
- Coarse lock (
sync.RWMutexon the whole tree): simple but serialises all readers β unacceptable when readers are the majority. - Lock coupling (hand-over-hand locking): lock parent before releasing child; correct but complex and still serialises the path from root to the insert point.
- RCU (Read-Copy-Update): readers are truly lock-free; writers copy the modified path and atomically swap the root pointer. The Linux kernel uses this for its red-black trees (e.g. the CFS schedulerβs task tree), but the implementation is hundreds of lines of careful pointer manipulation.
In contrast, a skip list writer only touches the O(log n) next[i] pointers
along the insertion path β no rebalancing, no rotations, no path-to-root
modifications. With careful use of atomic.Pointer, a skip list writer can
publish a new node without ever blocking a concurrent reader.
Structure: a linked list with express lanes
Start with a plain sorted singly-linked list. Finding a value takes O(n):
level 0: head β 10 β 20 β 30 β 40 β 50 β 60 β 70 β 80 β nil
Add an βexpress laneβ at level 1: every other node is also linked at level 1, so you skip two nodes per comparison at that level.
level 1: head βββββββ 20 βββββββ 40 βββββββ 60 βββββββ 80 β nil
level 0: head β 10 β 20 β 30 β 40 β 50 β 60 β 70 β 80 β nil
Add level 2 β skips every 4th node at level 0:
level 2: head βββββββββββββββββ 40 βββββββββββββββββ 80 β nil
level 1: head βββββββ 20 βββββββ 40 βββββββ 60 βββββββ 80 β nil
level 0: head β 10 β 20 β 30 β 40 β 50 β 60 β 70 β 80 β nil
With k levels and deterministic promotion (every 2^(k-1)-th node at level k-1), search is O(log n) β exactly like a binary search. But insert now requires updating O(log n) levels of pointers for every node and rebalancing when the structure drifts.
The skip list insight (Pugh, 1990): choose the number of levels for a new
node randomly, with probability p of gaining each additional level. This
collapses all deterministic restructuring into a single coin flip per insert.
Probabilistic level assignment
Each new node is assigned a level h drawn from a geometric distribution:
P(h = 1) = 1 - p β 75% (stays at level 1 only)
P(h = 2) = p(1-p) β 19% (reaches level 2)
P(h = 3) = pΒ²(1-p) β 5% (reaches level 3)
P(h = k) = p^(k-1) * (1-p)
With p = 0.25 (as in our implementation), a node needs 3 coin flips to reach level 3. The expected number of nodes at level k is:
E[nodes at level k] = n Β· p^(k-1)
n=1000, p=0.25:
level 1: 1000 nodes (all of them)
level 2: 250 nodes (every ~4th)
level 3: 63 nodes (every ~16th)
level 4: 16 nodes (every ~64th)
level 5: 4 nodes
This mimics the deterministic structure but with random spacing β the expected search time is O(log n) because the structure tracks a random approximation of the perfectly-balanced case.
Our randomLevel() generates this distribution directly:
// randomLevel returns a random level in [1, maxLevel] with geometric
// distribution at probability prob (0.25). Never returns 0.
func randomLevel() int {
lvl := 1
for lvl < maxLevel && rand.Float64() < prob {
lvl++
}
return lvl
}
Each iteration of the loop is one coin flip: continue to the next level with
probability 0.25, stop with probability 0.75. Because the loop exits at
maxLevel = 12, the maximum storable key count before height becomes a
bottleneck is roughly (1/p)^maxLevel = 4^12 β 16 million β far beyond our
4 MiB flush threshold.
Search algorithm
To find value x, start at the top-left (highest active level of the head
node). At each position:
- If
next.key < x: advance right (move to next node at this level). - If
next.key β₯ xornext == nil: drop down one level. - At level 0, if
next.key == x: found. - At level 0, if
next.key > xornext == nil: not present.
Traced example β search for 60 in a skip list with max 4 active levels:
level 3: head ββββββββββββββββββββββββββββββ 50 βββββββββββββββ nil
level 2: head ββββββ 10 βββββββββββββββββββ 50 βββ 80 ββββββ nil
level 1: head ββββββ 10 βββββββ 30 βββββββββ 50 βββ 80 ββββββ nil
level 0: head β 10 β 20 β 30 β 40 β 50 β 60 β 70 β 80 β 90 β nil
Start at head, level 3
L3: next=50, 50 < 60 β advance. Now at 50, L3.
L3: next=nil β drop to L2. Now at 50, L2.
L2: next=80, 80 > 60 β drop to L1. Now at 50, L1.
L1: next=80, 80 > 60 β drop to L0. Now at 50, L0.
L0: next=60, 60 == 60 β found.
5 comparisons. A plain list scan would take 6. With n=10,000 and 6 active levels, the expected comparisons drop from 10,000 to roughly 30.
Insert algorithm
Insert is a search that records its path, then splices the new node in:
// Put inserts or replaces the entry for key.
func (sl *SkipList) Put(key, value []byte) {
var update [maxLevel]*node // update[i] = rightmost node at level i with key < new key
cur := sl.head
for i := sl.level - 1; i >= 0; i-- {
for cur.next[i] != nil && CompareInternal(cur.next[i].key, key) < 0 {
cur = cur.next[i]
}
update[i] = cur // last node at level i that is still < key
}
// Exact duplicate: same internal key (same userKey + seqNum + type).
if n := update[0].next[0]; n != nil && CompareInternal(n.key, key) == 0 {
n.value = value
return
}
lvl := randomLevel()
if lvl > sl.level {
for i := sl.level; i < lvl; i++ {
update[i] = sl.head // new levels start from the head
}
sl.level = lvl
}
n := &node{key: key, value: value}
for i := 0; i < lvl; i++ {
n.next[i] = update[i].next[i] // point new node forward
update[i].next[i] = n // point predecessor to new node
}
sl.length++
}
Traced example β insert 35 (coin flips yield level h=2):
Level 1 search pass: last node with key < 35 β node(30) β update[0] = node(30)
Level 0 search pass: same result β update[1] = node(30)
Create node(35) with 2 levels.
Splice at level 1: 35.next[1] = 30.next[1] = 50; 30.next[1] = 35
Splice at level 0: 35.next[0] = 30.next[0] = 40; 30.next[0] = 35
Result:
L1: head β 10 β 30 β 35 β 50 β 80 β nil β 35 inserted
L0: head β 10 β 20 β 30 β 35 β 40 β 50 β 60 β 70 β 80 β 90 β nil
Only two pointer writes per level. No rotations. No path-to-root updates.
Why two pointer writes per level is sufficient: proof by invariant
The update array is provably correct because the search phase establishes a
strict invariant at every level i:
INVARIANT: after the search loop at level i,
update[i].key < newKey β€ update[i].next[i].key
(or update[i].next[i] == nil)
Proof: the inner loop for cur.next[i] != nil && Compare(cur.next[i].key, key) < 0
advances cur as long as the next nodeβs key is strictly less than newKey. It
stops precisely when cur.next[i].key β₯ newKey or cur.next[i] == nil. At that
moment, update[i] = cur β recording the last node that is strictly less than
newKey at this level.
Given that invariant, two pointer writes are exactly sufficient to splice n in:
Before: update[i] ββ[i]βββ successor
After: update[i] ββ[i]βββ n ββ[i]βββ successor
Write 1: n.next[i] = update[i].next[i] (= successor)
Write 2: update[i].next[i] = n
No other node at level i needs its pointers changed β n is correctly
positioned between its predecessor (already found) and successor (already known)
at every level. This is why skip list insert requires no global restructuring
whereas a red-black tree insert may modify any ancestor up to the root.
Node layout in memory
Our node struct:
type node struct {
key []byte // internal key (userKey + 8-byte tag)
value []byte
next [maxLevel]*node // array of 12 pointers, each 8 bytes on 64-bit
}
Memory cost per node on a 64-bit system:
key header: 16 bytes (slice: data pointer + length)
value header: 16 bytes (slice: data pointer + length)
next array: 96 bytes (12 Γ 8-byte pointer)
βββββββββββββββββββββββββββββββββββββββββββββββββββββ
struct total: 128 bytes + heap for key/value bytes
Only the levels actually used are populated; unused pointers are nil. A level-1 node uses 96 bytes for the array but only the first 8 bytes are non-nil β a small waste. Real LevelDB uses a variable-length node that allocates only as many pointer slots as the nodeβs level, saving 84 bytes for the common case.
Memory fragmentation: what 40,000 nodes actually costs
A full 4 MiB MemTable (FlushThreshold = 4 Γ 1024 Γ 1024 bytes) holds roughly:
FlushThreshold / (avg_internal_key_size + avg_value_size)
= 4,194,304 / (24 + 80) = ~40,000 nodes (assuming 16-byte user keys, 80-byte values)
Per-node heap cost breakdown (our fixed-array implementation):
node struct header: 128 bytes (2 slice headers Γ 16 B + 12 pointers Γ 8 B)
internal key bytes: 24 bytes (16 B user key + 8 B tag, avg)
value bytes: 80 bytes (avg)
Go allocator metadata: ~16 bytes (runtime size class header per heap object)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Total per node: ~248 bytes
For 40,000 nodes:
Total heap: 40,000 Γ 248 β 9.7 MiB
Notice: the MemTableβs size counter tracks only len(ikey) + len(value) per
call to Add β it does not account for the 128-byte struct overhead or the 16-byte
Go runtime metadata. ApproximateSize() therefore under-reports by roughly:
40,000 Γ (128 + 16) / (40,000 Γ (24 + 80)) = 144 / 104 β 1.38Γ
The actual heap usage is about 38% higher than ApproximateSize() reports.
This is acceptable β the flush threshold is a soft limit; briefly exceeding
FlushThreshold by 38% before a flush is triggered does not cause correctness
problems.
Pointer overhead breakdown:
Fixed-array waste per node:
Expected node level: E[h] = 1/(1-p) = 1/0.75 β 1.33 levels
Average occupied pointer slots: 1.33
Average wasted pointer slots: 12 - 1.33 = 10.67 wasted
Waste per node: 10.67 Γ 8 bytes = 85 bytes
Total wasted for 40,000 nodes: 40,000 Γ 85 β 3.3 MiB
Variable-length node (real LevelDB arena style):
Allocates exactly h pointer slots per node
Expected waste: 0
Total pointer memory: 40,000 Γ 1.33 Γ 8 β 426 KB
Our choice of [maxLevel]*node wastes ~3.3 MiB of zeroed pointer slots. The
trade-off is zero dynamic allocation of the next array β the struct size is known
at compile time, GC can trace it statically, and no arena allocator is needed.
Cache behaviour
The skip list has worse cache locality than a sorted array:
- Sorted array binary search: accesses cache-line-aligned contiguous memory; hardware prefetcher easily predicts the access pattern.
- Skip list search: pointer-chasing; each
cur.next[i]dereference may cause a cache miss (~4 ns L1, ~12 ns L2, ~50 ns L3, ~100 ns main memory on modern hardware).
At n=40,000 nodes (a full 4 MiB MemTable), the skip list makes ~8β15 pointer dereferences per lookup. At 50 ns each, that is ~400β750 ns β still much faster than any disk I/O (NVMe: ~100 Β΅s; HDD: ~10 ms). The cache miss cost is irrelevant compared to what follows (a WAL fdatasync), so the skip listβs simplicity wins over the sorted arrayβs cache friendliness.
Proving the cache miss cost claim with concrete numbers:
| Structure | n=40,000 lookup cost | Why |
|---|---|---|
| Sorted array (binary search) | ~15 comparisons Γ 2β4 ns = 30β60 ns | Contiguous memory; hardware prefetcher active; ~95% L1/L2 hit |
| Skip list | ~8β15 pointer hops Γ 4β50 ns = 32β750 ns | Random heap addresses; each dereference a potential cache miss |
| Hash map | ~1 lookup Γ 4β50 ns = 4β50 ns | O(1) but no ordering |
| Red-black tree | ~logβ(40,000) β 15 comparisons Γ 4β50 ns = 60β750 ns | Same pointer-chasing problem as skip list |
Typical cache miss latencies on a modern x86-64 system (Zen 4 / Ice Lake):
L1 hit: ~4 ns (4 KB per core)
L2 hit: ~12 ns (512 KB per core)
L3 hit: ~30 ns (shared, 8β32 MB)
DRAM: ~80 ns (64 ns CAS + controller latency)
NVMe SSD: ~100 Β΅s (PCIe 4.0 drive, queue depth 1)
HDD: ~8 ms (7200 RPM, average seek + rotation)
With 40,000 nodes spanning ~9.7 MiB of heap β exceeding most L3 caches β the working set of an active MemTable lives mostly in DRAM. Each pointer chase:
skip list node struct (128 B) = 2 cache lines (64 B each)
P(node in L3) for random access into 9.7 MiB β 0% (if L3 < 9.7 MiB)
β each right-move hop costs ~80 ns (DRAM)
~8 right-move hops Γ 80 ns = 640 ns per lookup
But the WAL fdatasync following every write costs 100 Β΅s (NVMe) or 5 ms (HDD). The 640 ns lookup cost is 0.6% of the NVMe fsync cost β statistical noise. This is the core argument for choosing the skip list over the sorted array despite worse cache behaviour.
Complexity summary
| Operation | Average | Worst case | Notes |
|---|---|---|---|
| Search | O(log n) | O(n) | Worst case probability: p^n β negligible |
| Insert | O(log n) | O(n) | Same search cost + O(h) splice |
| Delete | O(log n) | O(n) | Same as insert; not needed β tombstones instead |
| Iterator (full scan) | O(n) | O(n) | Level-0 linked list traversal |
| Space | O(n) expected | O(n log n) | Expected: n/(1-p) nodes total across all levels |
Proving the sorted-array O(n) insert claim
A sorted array stores keys contiguously. Inserting a new key at position k
requires shifting every element from k onward by one slot:
// Conceptual sorted-array insert β Go pseudocode:
func insertSorted(arr [][]byte, newKey []byte) [][]byte {
pos := sort.Search(len(arr), func(i int) bool {
return bytes.Compare(arr[i], newKey) >= 0
}) // O(log n) comparisons β fast
arr = append(arr, nil) // grow slice by one
copy(arr[pos+1:], arr[pos:]) // O(n-pos) pointer moves β slow
arr[pos] = newKey
return arr
}
The copy call is the problem. Consider a 4 MiB MemTable with 40,000 internal
keys. Each key is a []byte header (16 bytes on 64-bit). The slice of headers
occupies 40,000 Γ 16 = 640 KB.
Insertion position statistics for 40,000 random inserts:
pos=0 (prepend): 40,000 moves = 640 KB moved
pos=20,000 (middle): 20,000 moves = 320 KB moved
pos=39,999 (append): 1 move = 16 B moved
average: 20,000 moves = 320 KB per insert
At memory bandwidth of ~50 GB/s (DDR5 single-channel read+write):
320 KB per insert / 50,000,000 KB/s β 6.4 Β΅s per insert
40,000 inserts Γ 6.4 Β΅s / 2 (average fraction) β 128 ms total
That is 128 ms just for pointer moves before a single fdatasync. The skip list does 2 pointer writes per level Γ ~1.33 average levels = ~3 writes total per insert, taking under 10 ns β zero bulk memory movement.
Proving O(log n) expected search cost: backwards analysis
Pughβs original paper (1990) derives the expected search cost using backwards analysis: instead of asking βhow many nodes does the search visit?β, ask βif I were walking backwards from the found node to the head, which nodes would I have visited?β
Define C(k, n) = expected number of comparisons to climb k levels in a skip
list of n nodes. The recurrence is:
At any node at level i:
- With probability p: this node also exists at level i+1 (it was promoted).
β the backwards path goes up one level. Cost: 1 + C(k-1, ???)
- With probability (1-p): this node is only at level i.
β the backwards path goes left one step. Cost: 1 + C(k, n-1)
Recurrence:
C(k, n) = (1-p) Γ (1 + C(k, n-1)) + p Γ (1 + C(k-1, ???))
= 1 + (1-p)ΓC(k, n-1) + pΓC(k-1, ???)
Solving this recurrence (see Pugh 1990, Theorem 4) yields:
Expected comparisons β€ (log_{1/p}(n)) / p + 1/(1-p)
With p=0.25, n=40,000:
log_4(40,000) β 7.8
bound β€ 7.8 / 0.25 + 1/0.75
β€ 31.2 + 1.33
β 32.5 comparisons
This is an upper bound; the actual expected value is closer to
log_{1/p}(n) β 8 comparisons because many of those 32 βcomparisonsβ are
level-drops that reuse an already-loaded pointer without a new cache miss. In
practice, only forward advances (right moves) cause pointer chasing β and the
expected number of right moves is bounded by log_{1/p}(n) β 8.
Making the skip list concurrent-safe
Our skip list is not safe for concurrent use β the comment in skiplist.go
makes this explicit. The DB struct (lab 03) holds a sync.Mutex that
serialises all access. Real engines allow concurrent lock-free reads.
The race condition is between a writer executing:
// Writer splice at level i:
n.next[i] = update[i].next[i] // Step A: set n's forward pointer
update[i].next[i] = n // Step B: publish n to readers
and a concurrent reader executing:
// Reader advance at level i:
if cur.next[i] != nil && Compare(cur.next[i].key, key) < 0 {
cur = cur.next[i] // loads update[i].next[i]
}
If the reader loads update[i].next[i] between Steps A and B, it sees the
old successor (before n was inserted) β which is correct. If it loads after
Step B, it sees n β also correct. The only unsafe scenario is if the CPU or
compiler reorders Steps A and B so that Step B becomes visible to a reader
before Step A completes.
Goβs memory model guarantees that a plain assignment is not an atomic store.
A reader on another goroutine can observe a partial state where update[i].next[i]
points to n but n.next[i] is still zero (nil).
The fix β what RocksDBβs InlineSkipList does β is to use atomic operations:
// Lock-free node struct (Go 1.19+):
type node struct {
key []byte
value []byte
next [maxLevel]atomic.Pointer[node]
}
// Writer splice β sequential store order matters:
for i := 0; i < lvl; i++ {
// Set n's forward pointer BEFORE publishing n as reachable:
n.next[i].Store(update[i].next[i].Load())
// Now publish atomically β any reader that sees n also sees n.next[i]:
update[i].next[i].Store(n)
}
// Reader advance β load atomically:
cur = cur.next[i].Load()
The atomic.Pointer.Store issues a release fence; Load issues an acquire
fence. This means any reader that loads n from update[i].next[i] is
guaranteed to observe n.next[i] as set by Step A β the happens-before
relationship is established.
This works for single-writer / many-readers (our MemTable pattern). For multiple concurrent writers, each writerβs splice would need a CAS loop:
// CAS splice β retry if another writer raced:
for {
old := update[i].next[i].Load()
n.next[i].Store(old) // set n's forward pointer to current successor
if update[i].next[i].CompareAndSwap(old, n) {
break // success: we atomically claimed the slot
}
// Another writer beat us β reload and retry
}
Our implementation skips all of this. The cost is a mutex acquisition on every read and write β but for a single-threaded write path (WAL serialises writes), the mutex overhead (~20 ns uncontended) is acceptable.
Concept: Internal Keys and MVCC
What is MVCC?
Multi-Version Concurrency Control is a concurrency strategy where the database never overwrites a value in place. Every write creates a new version of the key tagged with a monotonically increasing sequence number. Old versions coexist with new ones until a background process (compaction) removes them.
This solves two problems that plague simple key-value stores:
Problem 1 β Snapshot isolation: a long-running scan started at time T should see the data as it existed at T, not as modified by concurrent writers. With a single-version store, a writer can change a key between two reads in the same scan β breaking consistency.
Problem 2 β Delete vs. read race: if Delete("k") immediately removes the
entry, a concurrent Get("k") that already passed the βkey existsβ check may
find nothing. With MVCC, Delete inserts a tombstone β a special version
that says βkey k was deleted at seqNum Nβ β rather than removing any data.
Both problems disappear when a reader is assigned a fixed readSeq and ignores
all versions with seqNum > readSeq.
Visualising multiple versions
Timeline of writes:
seqNum=1: Put("name", "Alice")
seqNum=2: Put("city", "London")
seqNum=3: Put("name", "Bob") β Alice still in MemTable
seqNum=4: Delete("city") β London still in MemTable
What lives in the MemTable (sorted by internal key):
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β internal key β value β visible at β
ββββββββββββββββββββββββββββββββββΌβββββββββΌβββββββββββββββββββ€
β ("city", seq=4, typeDelete) β nil β readSeq β₯ 4 β
β ("city", seq=2, typeValue) β"London"β readSeq 2..3 β
β ("name", seq=3, typeValue) β "Bob" β readSeq β₯ 3 β
β ("name", seq=1, typeValue) β"Alice" β readSeq 1..2 β
ββββββββββββββββββββββββββββββββββ΄βββββββββ΄βββββββββββββββββββ
A reader at readSeq=2 calls Get("name", 2):
- Builds lookup key
("name", seq=2, typeValue). - Skip list positions on the first internal key β₯ that lookup.
- Due to descending seqNum sort:
("name", seq=3)sorts before("name", seq=2). The first key β₯ lookup is("name", seq=2)itself. - seq=2 β€ readSeq=2 and type=typeValue β return βAliceβ. β
The same reader calling Get("city", 2):
- Lookup key:
("city", seq=2, typeValue). - First key β₯ lookup:
("city", seq=2)β seq=2 β€ readSeq=2, typeValue β βLondonβ. β
A reader at readSeq=4 calling Get("city", 4):
- Lookup key:
("city", seq=4, typeValue). - First key β₯ lookup:
("city", seq=4, typeDelete)β type=typeDelete β return not found. β
No lock was held. No reader was blocked by a writer.
MVCC in other database systems
MVCC is not unique to LevelDB. Comparing implementations reveals what each design trades off.
PostgreSQL β heap tuple versioning
PostgreSQL stores every version of a row directly in the heap file (the tableβs data file). Each tuple (row version) carries a 23-byte system header:
/* From PostgreSQL src/include/access/htup_details.h (simplified) */
typedef struct HeapTupleHeaderData {
TransactionId t_xmin; /* 4 B β txn ID that inserted this tuple */
TransactionId t_xmax; /* 4 B β txn ID that deleted/updated it (0=live) */
CommandId t_cid; /* 4 B β command within the transaction */
ItemPointerData t_ctid;/* 6 B β pointer to newer version (chain) */
/* ... 5 more bytes of flags ... */
} HeapTupleHeaderData; /* total: 23 bytes before the actual row data */
A reader with transaction ID T sees a tuple when:
t_xmincommitted ANDt_xmin β€ T(the insert is visible)t_xmax == 0ORt_xmax > TORt_xmaxaborted (not yet deleted)
This is exactly our model with different names:
| PostgreSQL | Our engine |
|---|---|
t_xmin | seqNum of the Put |
t_xmax | seqNum of the Delete tombstone |
TransactionId | uint64 seqNum |
VACUUM | compaction (lab 06) |
| heap file | skip list + SSTable |
Key difference: PostgreSQL stores versions in an unsorted heap (rows are
appended in transaction order, not key order). A B-tree index maps user keys to
heap positions. Our skip list is both the storage and the index β no separate
index structure needed. The cost: skip list scans must visit every version of a
key; the PostgreSQL heap only stores one version per page slot and chains to older
versions via t_ctid.
Oracle / MySQL InnoDB β undo log chain
Oracle and InnoDB take the opposite approach: the primary table stores only the latest version. Older versions live in a separate undo log:
Primary row: key β current_value (always the newest)
Undo log: [txn_id, before_image, pointer_to_older_undo_entry]
[txn_id, before_image, pointer_to_older_undo_entry]
...
A reader that needs an older version reconstructs it by reading the current row and applying undo entries in reverse until reaching its snapshot point.
Pros: the primary table never grows with old versions; reads that need only the latest version are maximally fast; the table file stays compact.
Cons: a reader with an old snapshot that needs a heavily-updated row may need
to chase many undo log entries (βlong undo chainβ) β O(versions_since_snapshot)
per row. This is why SELECT * on a hot table during a long-running OLAP query
can be slow: every rowβs history must be reconstructed.
LevelDB / RocksDB β inline append-only versions
Our design (and RocksDBβs): all versions coexist inline in the skip list and SSTables. No undo log. No separate version chain to follow.
Pros: lookup cost is O(log n) regardless of how many versions exist β the seek lands directly on the right version without chasing any pointers.
Cons: space grows with write traffic until compaction runs. A key updated 1000 times occupies 1000 entries in the skip list; in InnoDB it occupies 1 row
- 1000 undo log entries (same space, but the undo log is compressible and separate).
| Feature | PostgreSQL | Oracle/InnoDB | LevelDB/RocksDB |
|---|---|---|---|
| Old version location | Heap file (inline) | Undo log (separate) | Skip list / SSTable (inline) |
| Latest version lookup | Index β heap O(log n) | Index β row O(log n) | Skip list O(log n) |
| Old version lookup | Heap scan | Undo chain O(versions) | Seek O(log n) |
| Cleanup mechanism | VACUUM (background) | Undo purge (background) | Compaction (background) |
| Write amplification | 1Γ per tuple | 1Γ row + 1Γ undo | 1Γ WAL + compaction |
Internal key format: byte layout
User keys are never stored raw in the skip list or on disk. Every entry is stored under a composite internal key:
ββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ
β userKey (N bytes, arbitrary) β tag (8 bytes, little-endian uint64) β
β β = (seqNum << 8) | uint64(keyType) β
ββββββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββ
The 8-byte tag encodes both the sequence number and the key type:
bit 63 bit 8 bit 7 bit 0
ββββββββββββββββββββββββββββ¬βββββββββββββββββββ
β seqNum (56 bits) β keyType (8 bits) β
ββββββββββββββββββββββββββββ΄βββββββββββββββββββ
keyType: 0 = TypeDelete (deletion tombstone)
1 = TypeValue (live value)
seqNum range: 0 .. 2^56-1 = 72,057,594,037,927,935
EncodeInternalKey from our key.go:
// EncodeInternalKey encodes userKey + seqNum + kt into a single byte slice.
func EncodeInternalKey(userKey []byte, seqNum uint64, kt KeyType) []byte {
buf := make([]byte, len(userKey)+8)
copy(buf, userKey)
tag := (seqNum << 8) | uint64(kt)
binary.LittleEndian.PutUint64(buf[len(userKey):], tag)
return buf
}
Concrete example β EncodeInternalKey([]byte("name"), 3, TypeValue):
userKey = "name" = [0x6e 0x61 0x6d 0x65]
seqNum = 3, kt = TypeValue = 1
tag = (3 << 8) | 1 = 0x0000000000000301
Little-endian 8 bytes of 0x0000000000000301:
byte 0: 0x01 (least significant)
byte 1: 0x03
byte 2: 0x00
byte 3: 0x00
byte 4: 0x00
byte 5: 0x00
byte 6: 0x00
byte 7: 0x00 (most significant)
Internal key bytes:
[0x6e, 0x61, 0x6d, 0x65, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
n a m e βββββββββββ tag LE ββββββββββββββββββββββββββββββ
DecodeInternalKey reverses this:
// DecodeInternalKey splits an internal key back into its components.
func DecodeInternalKey(b []byte) (userKey []byte, seqNum uint64, kt KeyType) {
tag := binary.LittleEndian.Uint64(b[len(b)-8:])
return b[:len(b)-8], tag >> 8, KeyType(tag & 0xff)
}
tag >> 8 shifts out the keyType byte and recovers the seqNum.
tag & 0xff masks the lower 8 bits and recovers the keyType.
Why the tag is little-endian
The tag is stored with binary.LittleEndian β the least significant byte first.
This means the keyType byte (& 0xff) is at offset 0 of the tag field (the
byte immediately after the user key).
Compare two internal keys for the same userKey at different seqNums:
("name", seq=1, TypeValue):
bytes: ... 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
β seq=1 in bits 63..8
("name", seq=3, TypeValue):
bytes: ... 0x01 0x03 0x00 0x00 0x00 0x00 0x00 0x00
β seq=3 in bits 63..8
A plain bytes.Compare on these two byte slices would sort seq=1 before
seq=3 β wrong for MVCC (we want highest seqNum first). This is why we do not
use bytes.Compare on internal keys directly.
CompareInternal: the comparator
// CompareInternal compares two internal keys.
// Primary sort: userKey ascending.
// Secondary sort: seqNum descending (higher seqNum sorts first).
func CompareInternal(a, b []byte) int {
ukA, seqA, _ := DecodeInternalKey(a)
ukB, seqB, _ := DecodeInternalKey(b)
if c := bytes.Compare(ukA, ukB); c != 0 {
return c // different user keys: lexicographic order
}
// Same user key: invert seqNum so higher comes first.
if seqA > seqB {
return -1 // a has higher seqNum β a sorts before b
}
if seqA < seqB {
return 1
}
return 0
}
The inversion (seqA > seqB β return -1) is the critical line. It makes the
skip list treat a key with seqNum=10 as βsmallerβ (earlier in sorted order) than
the same key with seqNum=5 β the opposite of the natural number ordering.
Why? Because all algorithms (skip list search, SSTable binary search,
iterator) find the first element β₯ the lookup key. If higher seqNums sort
first, a lookup key of (userKey, readSeq) lands directly on the most recent
version β€ readSeq. With ascending seqNum order, you would have to scan backward
from the oldest version β which the forward-only skip list cannot do efficiently.
MemTable.Get: step-by-step
func (m *MemTable) Get(userKey []byte, readSeq uint64) ([]byte, bool) {
// Step 1: build a lookup key with the caller's readSeq.
// TypeValue is used here because TypeValue = 1 > TypeDelete = 0,
// making the lookup key sort *before* a TypeDelete entry at the same
// seqNum β ensuring the TypeDelete is found if it exists.
lookupKey := EncodeInternalKey(userKey, readSeq, TypeValue)
// Step 2: skip-list seek β find the rightmost node < lookupKey.
cur := m.sl.head
for i := m.sl.level - 1; i >= 0; i-- {
for cur.next[i] != nil && CompareInternal(cur.next[i].key, lookupKey) < 0 {
cur = cur.next[i]
}
}
n := cur.next[0] // first node >= lookupKey
// Step 3: validate the result.
if n == nil {
return nil, false // past the end of the skip list
}
uk, seq, kt := DecodeInternalKey(n.key)
if string(uk) != string(userKey) || seq > readSeq {
return nil, false // moved to a different user key, or version too new
}
// Step 4: interpret keyType.
if kt == TypeDelete {
return nil, false // tombstone: key was deleted at seq β€ readSeq
}
return n.value, true
}
Worked example β Get("name", readSeq=2) against the MemTable containing:
Skip list order (internal keys):
[0] ("city", seq=4, TypeDelete)
[1] ("city", seq=2, TypeValue) = "London"
[2] ("name", seq=3, TypeValue) = "Bob"
[3] ("name", seq=1, TypeValue) = "Alice"
- Build lookupKey =
("name", seq=2, TypeValue). CompareInternalsorts it between [1] and [2]:("city", seq=2)<("name", seq=2)because βcityβ < βnameβ.("name", seq=3)<("name", seq=2)because seq=3 > seq=2 (inverted).("name", seq=1)>("name", seq=2)because seq=1 < seq=2 (inverted).
- Skip list seek stops at [1] (last node < lookupKey), so
n = [2]which is("name", seq=3). uk="name" == userKey, butseq=3 > readSeq=2β skip; step 3 returns false.
Wait β this returns not found, but βAliceβ exists at seq=1 β€ readSeq=2. Is the implementation wrong?
No. The Get implementation finds the most recent version β€ readSeq.
There is no version at exactly seq=2, and the only candidate is seq=3 which is
newer than readSeq=2. The implementation correctly skips it.
But then seq=1 is never reached β is Alice lost?
Let us re-examine. The skip list seek finds n = first node β₯ lookupKey.
lookupKey is ("name", seq=2, TypeValue).
With the descending seqNum sort:
("name", seq=3)has internal comparison value βless thanβ("name", seq=2)β because higher seqNum sorts first, seq=3 comes before seq=2 in the skip list.("name", seq=1)has internal comparison value βgreater thanβ("name", seq=2).
So the sorted order in the skip list is:
... ("name", seq=3) β ("name", seq=2 would go here) β ("name", seq=1) ...
The seek returns n = first node β₯ ("name", seq=2) = ("name", seq=1).
Now step 3: seq=1 β€ readSeq=2 and kt=TypeValue β return n.value = "Alice". β
The key insight: the lookup key itself does not exist in the list. The first node β₯ the lookup key is the closest version not newer than readSeq β exactly the MVCC semantics we need.
Iterator design: level-0 traversal
All compaction-path access (lab 06) and WAL-replay verification goes through the
iterator. The full iterator API from skiplist.go:
// SlIter is a forward-only iterator over a SkipList.
type SlIter struct {
cur *node // currently pointed-at node; nil when exhausted
}
// SeekToFirst positions the iterator at the first real element.
// head.next[0] is the first node that carries an actual key.
func (it *SlIter) SeekToFirst(sl *SkipList) {
it.cur = sl.head.next[0] // skip the sentinel head (key == nil)
}
// Next advances one step along the level-0 linked list.
// Time: O(1) β just follow one pointer.
func (it *SlIter) Next() { it.cur = it.cur.next[0] }
// Valid reports whether the iterator is on a real node.
func (it *SlIter) Valid() bool { return it.cur != nil && it.cur.key != nil }
// Key and Value return the current internal key and value.
func (it *SlIter) Key() []byte { return it.cur.key }
func (it *SlIter) Value() []byte { return it.cur.value }
The iterator only uses level 0. The higher levels exist solely for O(log n) point lookups. Level 0 is a complete linked list of every node in sorted order β the iterator simply walks it left to right.
Why the iterator sees MVCC versions in the right order for compaction:
Compaction needs to merge multiple MemTable/SSTable iterators and keep only the most recent version of each user key below the GC watermark. Because internal keys sort with seqNum descending, the iterator produces versions from newest to oldest for each user key:
Iterator output for MemTable containing:
Put("apple", "green", seq=2)
Put("apple", "red", seq=4)
Put("cherry", "red", seq=1)
Iteration sequence (level-0 order):
Step 1: ("apple", seq=4, TypeValue) β "red" β newest apple first
Step 2: ("apple", seq=2, TypeValue) β "green"
Step 3: ("cherry", seq=1, TypeValue) β "red"
The compaction algorithm (lab 06) exploits this property:
PSEUDO-CODE β compaction iterator logic:
prevUserKey = ""
for each entry in merged iterator:
uk, seq, kt = DecodeInternalKey(entry.Key)
if uk == prevUserKey:
// A newer version already emitted β drop this older version
continue
if kt == TypeDelete && seq < gcWatermark:
// Tombstone below watermark β drop it (no reader can see the old value)
prevUserKey = uk
continue
emit(entry) // keep this entry in the new SSTable
prevUserKey = uk
The descending seqNum sort guarantees that uk == prevUserKey on the second
and subsequent versions β making βdrop older versionsβ a trivial string
comparison rather than a sorted set lookup.
Iterator and MemTable.NewIterator:
// NewIterator returns a forward iterator positioned at the first element.
func (m *MemTable) NewIterator() *SlIter {
it := &SlIter{}
it.SeekToFirst(m.sl) // position at head.next[0]
return it
}
The caller uses it as:
it := mem.NewIterator()
for ; it.Valid(); it.Next() {
uk, seq, kt := DecodeInternalKey(it.Key())
// process entry...
}
What the MemTable wraps
MemTable is a thin wrapper that translates between the public API
(userKey []byte, seqNum, keyType) and the raw skip listβs internal key
format:
// Add inserts a record into the MemTable.
func (m *MemTable) Add(seqNum uint64, kt KeyType, userKey, value []byte) {
ikey := EncodeInternalKey(userKey, seqNum, kt)
m.sl.Put(ikey, value)
m.size += int64(len(ikey) + len(value))
}
The size field tracks approximate memory usage. When it exceeds
FlushThreshold (4 MiB, added in lab 05), the MemTable is frozen and a fresh
one takes its place.
Running the tests
The test file exercises four distinct behaviours:
// TestSeqNumMVCC confirms that two writes to the same key at different
// sequence numbers are independently readable.
func TestSeqNumMVCC(t *testing.T) {
m := NewMemTable()
m.Add(1, TypeValue, []byte("k"), []byte("v1"))
m.Add(2, TypeValue, []byte("k"), []byte("v2"))
v, ok := m.Get([]byte("k"), 1) // readSeq=1 β v1
if !ok || string(v) != "v1" {
t.Errorf("readSeq=1: want v1 got %q ok=%v", v, ok)
}
v, ok = m.Get([]byte("k"), 2) // readSeq=2 β v2
if !ok || string(v) != "v2" {
t.Errorf("readSeq=2: want v2 got %q ok=%v", v, ok)
}
}
cd leveldb
go test ./lab02/... -v -count=1
Expected:
=== RUN TestBasicPutGet
--- PASS: TestBasicPutGet (0.00s)
=== RUN TestDeleteReturnsNotFound
--- PASS: TestDeleteReturnsNotFound (0.00s)
=== RUN TestSeqNumMVCC
--- PASS: TestSeqNumMVCC (0.00s)
=== RUN TestIteratorSortedOrder
--- PASS: TestIteratorSortedOrder (0.00s)
PASS
ok github.com/10xdev/leveldb/lab02
Running the demo
go run ./lab02/demo
The demo writes five entries and then:
- Iterates the full MemTable β showing internal-key sorted order (userKey ASC, seqNum DESC within each user key).
- Reads βappleβ at three different readSeq values β demonstrating point-in-time snapshot reads without any locking.
Expected output:
add seq=1 banana = yellow
add seq=2 apple = green
add seq=3 cherry = red
add seq=4 apple = red
add seq=5 date = brown
--- sorted iteration ---
apple seq=4 val=red
apple seq=2 val=green
banana seq=1 val=yellow
cherry seq=3 val=red
date seq=5 val=brown
--- MVCC get for 'apple' ---
readSeq=2 apple = green
readSeq=4 apple = red
readSeq=10 apple = red
Notice:
- βappleβ appears twice in the iteration β both versions are retained.
- seq=4 comes before seq=2 in the output β descending seqNum sort.
readSeq=10returns the latest value (βredβ) because no version has seqNum > 4.
FoundationDB parallel
In FoundationDB, every committed transaction receives a globally ordered
read version (a 64-bit integer). tr.SetReadVersion(v) is the exact
equivalent of our readSeq parameter β it pins the snapshot to a specific
point in history. tr.GetReadVersion() returns the current cluster version,
equivalent to db.seqNum in our engine.
FDBβs storage servers maintain multiple committed versions of each key in their B-tree, cleaned up by a background GC process once the GC watermark (the oldest active read version across all clients) advances β directly equivalent to compaction dropping old MVCC versions in lab 06.
The key architectural difference: FDBβs version counter is maintained by the
sequencer (a single process elected by Paxos that assigns versions to
transactions across the entire cluster), ensuring total order across many
concurrent clients. Our db.seqNum is a per-process counter with no network
coordination β sufficient for a single-node engine.
Lab 03 β Write Path: WAL β MemTable + Crash Recovery
- What this lab adds
- Concept: Why WAL-first is necessary
- Concept: Write Batches and Atomicity
WriteBatchdata structureWriteBatchwire formatDecode(): a parse state machineReplay(): applying the batch to the MemTable- The
DBstruct: field anatomy DB.Putexecution trace: end to endrecoverFromWAL: how the MemTable is rebuilt- Crash scenarios: byte-level analysis
- Group commit: the missing optimisation
- Running the tests
- Running the demo
- FoundationDB parallel
What this lab adds
Labs 01 and 02 built the two durable pieces separately:
- Lab 01: the WAL β a CRC-framed append-only log on disk
- Lab 02: the MemTable + MVCC β an in-memory sorted skip list with versioned keys
Lab 03 wires them together into a working durable key-value store:
- Every
Put/Deleteis first serialised into aWriteBatch. - The batch is appended to the WAL (lab 01) before touching memory.
- The batch is replayed into the MemTable (lab 02).
- On
Open, any existing WAL is replayed from start to finish, rebuilding the MemTable β so a crash loses nothing that was acknowledged.
The result is a store that survives crashes with no data loss for any operation that returned successfully to the caller.
Concept: Why WAL-first is necessary
The single-threaded write problem
Consider a naΓ―ve implementation that writes to memory first, then WAL:
WRONG ORDER:
1. mem.Add(seqNum, TypeValue, key, value) β in-memory only
2. wal.Append(encoded) β power fails here
3. seqNum++
If power fails at step 2, the value is in memory but not on disk. On the next
Open, the WAL is empty β the write is silently lost. The caller received no
error and has no way to know the data is gone.
WAL-first: the correct order
CORRECT ORDER:
1. wal.Append(encoded) β write to disk, fsync
(power can fail here safely β WAL record not complete, nothing was acked)
2. mem.Add(...) β apply to memory
3. seqNum++
If power fails during step 1 (mid-write), the WAL record is either absent or
has a bad CRC. The lab-01 Recover function stops at the first corrupt record.
No acknowledgement was sent to the caller (because wal.Append had not
returned), so no data is lost from the callerβs perspective.
If power fails after step 1 completes, recovery replays the record and
re-applies it to the MemTable. The callerβs Put had returned successfully,
so the data is properly durable.
The four safety windows
Timeline of a single Put("name", "Alice"):
T0 ββββ applyBatch called ββββββββββββββββββββββββββββββββββββββββββββββ
T1 β b.Encode() β []byte (in memory, no I/O)
T2 β wal.Append(bytes) βββββββββββββββββββ CRASH SAFE: nothing acked
T3 β pwrite(fd, header+payload, ...) (kernel buffer)
T4 β fdatasync(fd) (durable on disk) βββ T4
T5 β b.Replay(mem) (MemTable updated)
T6 β db.seqNum++ (MVCC clock advanced)
T7 ββββ applyBatch returns (caller acked) βββββββββββββββββββββββββββββ
| Crash at | WAL state | MemTable state | Recovery outcome |
|---|---|---|---|
| T1βT3 | No record | Not updated | Recover sees nothing; write never acknowledged β no data loss |
| T3βT4 | Partial record | Not updated | Recover stops at CRC failure; write never acknowledged β no data loss |
| T4βT5 | Full record | Not updated | Recover replays record; data restored β |
| T5βT6 | Full record | Updated | Recover replays record (idempotent); data intact β |
| After T7 | Full record | Updated | Normal state; no recovery needed |
This table proves that WAL-first gives at-least-once application of every acknowledged write. Exactly-once is guaranteed by internal key uniqueness: the MemTable deduplicates on internal key, so replaying the same write twice produces the same result.
Concept: Write Batches and Atomicity
Why batching is needed
A WriteBatch groups one or more operations that must appear atomically: either
all are visible, or none are. Without batching, a multi-key update that is
interrupted mid-way leaves the database in a partially-updated state.
Bank transfer example:
Without batching (WRONG β can be interrupted between writes):
db.Put([]byte("account:A"), newBalanceA) β WAL record 1 β durable
db.Put([]byte("account:B"), newBalanceB) β power fails here!
β account A debited, account B not credited β money disappears
With batching (CORRECT β both or neither):
b := &WriteBatch{}
b.Put([]byte("account:A"), newBalanceA)
b.Put([]byte("account:B"), newBalanceB)
db.Write(b)
β single WAL record; recovery replays both or neither
Sequence number assignment at batch granularity
Sequence numbers are assigned at the batch level: all operations in a batch
share the same SeqNum. This means every key in a batch becomes visible
simultaneously at the same seqNum β which is the atomic boundary.
Batch at seqNum=100 with 3 ops:
op[0]: Put("k1", "v1") β MemTable: internal key ("k1", seq=100, TypeValue)
op[1]: Put("k2", "v2") β MemTable: internal key ("k2", seq=100, TypeValue)
op[2]: Delete("k3") β MemTable: internal key ("k3", seq=100, TypeDelete)
β db.seqNum advances from 100 to 101 after the batch
A reader at readSeq=99 sees none of them. A reader at readSeq=100 sees all
three. There is no intermediate state where some operations are visible and
others are not.
Note: our current
DB.Putwraps a single operation per batch and advancesseqNumby 1. TheDB.WriteAPI allows multi-op batches. The testTestBatchWriteexercises this path.
WriteBatch data structure
// batchOp is a single operation inside a WriteBatch.
type batchOp struct {
del bool // true = Delete; false = Put
key []byte
val []byte // nil for Delete
}
// WriteBatch accumulates operations that will be applied atomically.
type WriteBatch struct {
SeqNum uint64 // assigned by DB.applyBatch just before encoding
ops []batchOp // slice grows as Put/Delete are called
}
Put and Delete simply append to ops:
func (b *WriteBatch) Put(key, value []byte) {
b.ops = append(b.ops, batchOp{key: key, val: value})
}
func (b *WriteBatch) Delete(key []byte) {
b.ops = append(b.ops, batchOp{del: true, key: key})
}
The batch carries no locks and performs no I/O. It is a pure in-memory
accumulator until DB.Write (or DB.Put / DB.Delete) calls applyBatch.
WriteBatch wire format
A WriteBatch is serialised to bytes before being handed to the WAL. The
format is a fixed 12-byte header followed by one record per operation:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 12-byte header β
β seqNum (8 bytes, little-endian uint64) β
β count (4 bytes, little-endian uint32) β number of ops β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β op record 0: β
β type (1 byte) 'p' = Put (0x70), 'd' = Delete (0x64) β
β kLen (4 bytes, little-endian uint32) β
β key (kLen bytes) β
β vLen (4 bytes, little-endian uint32) β Put only β
β value (vLen bytes) β Put only β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β op record 1 ... β
β op record N-1 ... β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Encode() annotated
func (b *WriteBatch) Encode() []byte {
// Pre-calculate total size to avoid reallocations:
sz := 8 + 4 // header: 8B seqNum + 4B count
for _, op := range b.ops {
sz += 1 + 4 + len(op.key) // type byte + 4B key length + key bytes
if !op.del {
sz += 4 + len(op.val) // 4B value length + value bytes (Put only)
}
}
buf := make([]byte, 0, sz) // single allocation, exact capacity
// Write 12-byte header:
var hdr [12]byte
binary.LittleEndian.PutUint64(hdr[0:8], b.SeqNum) // bytes 0-7
binary.LittleEndian.PutUint32(hdr[8:12], uint32(len(b.ops))) // bytes 8-11
buf = append(buf, hdr[:]...)
// Write each operation record:
for _, op := range b.ops {
if op.del {
buf = append(buf, 'd') // 0x64
} else {
buf = append(buf, 'p') // 0x70
}
var klen [4]byte
binary.LittleEndian.PutUint32(klen[:], uint32(len(op.key)))
buf = append(buf, klen[:]...)
buf = append(buf, op.key...)
if !op.del {
var vlen [4]byte
binary.LittleEndian.PutUint32(vlen[:], uint32(len(op.val)))
buf = append(buf, vlen[:]...)
buf = append(buf, op.val...)
}
}
return buf
}
Byte-level example: Put("name", "Alice") at seqNum=1
Input:
SeqNum = 1
ops[0] = {del: false, key: "name", val: "Alice"}
Computation:
seqNum=1: little-endian uint64 = 01 00 00 00 00 00 00 00
count=1: little-endian uint32 = 01 00 00 00
type='p': 0x70
kLen=4: little-endian uint32 = 04 00 00 00
key="name": 6e 61 6d 65
vLen=5: little-endian uint32 = 05 00 00 00
val="Alice": 41 6c 69 63 65
Encoded bytes (26 bytes total):
offset 0: 01 00 00 00 00 00 00 00 β seqNum = 1
offset 8: 01 00 00 00 β count = 1
offset 12: 70 β type = 'p' (Put)
offset 13: 04 00 00 00 β kLen = 4
offset 17: 6e 61 6d 65 β key = "name"
offset 21: 05 00 00 00 β vLen = 5
offset 25: 41 6c 69 63 65 β val = "Alice"
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
total: 26 bytes
This 26-byte payload is then wrapped by the lab-01 WAL framing (7-byte header + 4-byte CRC = 37 bytes on disk total for this single write).
Byte-level example: two-op batch
Put("a", "1") + Delete("b") at seqNum=5:
offset 0: 05 00 00 00 00 00 00 00 β seqNum = 5
offset 8: 02 00 00 00 β count = 2
op[0]:
offset 12: 70 β type = 'p'
offset 13: 01 00 00 00 β kLen = 1
offset 17: 61 β key = "a"
offset 18: 01 00 00 00 β vLen = 1
offset 22: 31 β val = "1"
op[1]:
offset 23: 64 β type = 'd' (Delete)
offset 24: 01 00 00 00 β kLen = 1
offset 28: 62 β key = "b"
(no vLen or val for Delete)
βββββββββββββββββββββββββββββββββββββ
total: 29 bytes
Delete ops are shorter: no value bytes, saving 4 + len(value) bytes per
deleted key.
Decode(): a parse state machine
Decode is the inverse of Encode. It reads the fixed header and then
iterates through the variable-length per-op records:
func Decode(data []byte) (*WriteBatch, error) {
// Guard: payload must be at least 12 bytes (header only, zero ops is valid).
if len(data) < 12 {
return nil, errors.New("batch: payload too short")
}
b := &WriteBatch{}
b.SeqNum = binary.LittleEndian.Uint64(data[0:8]) // bytes 0-7
count := binary.LittleEndian.Uint32(data[8:12]) // bytes 8-11
pos := 12 // cursor starts after the header
for i := uint32(0); i < count; i++ {
// Read 1-byte type field:
if pos >= len(data) {
return nil, errors.New("batch: truncated record")
}
typ := data[pos]; pos++
// Read 4-byte key length + key bytes:
if pos+4 > len(data) {
return nil, errors.New("batch: truncated key length")
}
klen := int(binary.LittleEndian.Uint32(data[pos : pos+4])); pos += 4
if pos+klen > len(data) {
return nil, errors.New("batch: truncated key")
}
key := make([]byte, klen)
copy(key, data[pos:pos+klen]); pos += klen // copy: avoid aliasing WAL buffer
switch typ {
case 'd': // Delete: no value fields
b.ops = append(b.ops, batchOp{del: true, key: key})
case 'p': // Put: read 4-byte value length + value bytes
if pos+4 > len(data) {
return nil, errors.New("batch: truncated value length")
}
vlen := int(binary.LittleEndian.Uint32(data[pos : pos+4])); pos += 4
if pos+vlen > len(data) {
return nil, errors.New("batch: truncated value")
}
val := make([]byte, vlen)
copy(val, data[pos:pos+vlen]); pos += vlen
b.ops = append(b.ops, batchOp{key: key, val: val})
default:
return nil, fmt.Errorf("batch: unknown op type %q", typ)
}
}
return b, nil
}
Why copy on key and value? The data slice comes from the WALβs read
buffer. If we stored data[pos:pos+klen] directly, the key would share memory
with the WAL buffer β modifying or discarding the WAL buffer would corrupt the
batch. copy ensures the batch owns its key/value bytes.
Why explicit bounds checks before every read? The WAL guarantees CRC integrity per record, so a correctly-written record will always decode cleanly. But during development (or if a third-party tool corrupts the WAL), the bounds checks return descriptive errors rather than panicking on a nil-slice index.
Traced decode of the two-op batch above (29 bytes, seqNum=5):
pos=0: read seqNum = 0x0000000000000005 = 5 (bytes 0-7)
pos=8: read count = 0x00000002 = 2 (bytes 8-11)
pos=12: start loop i=0
pos=12: typ = 0x70 = 'p' posβ13
pos=13: klen = 0x00000001 = 1 posβ17
pos=17: key = [0x61] = "a" posβ18
case 'p':
pos=18: vlen = 0x00000001 = 1 posβ22
pos=22: val = [0x31] = "1" posβ23
append batchOp{del:false, key:"a", val:"1"}
pos=23: start loop i=1
pos=23: typ = 0x64 = 'd' posβ24
pos=24: klen = 0x00000001 = 1 posβ28
pos=28: key = [0x62] = "b" posβ29
case 'd':
append batchOp{del:true, key:"b"}
pos=29: loop done, count=2 satisfied
β WriteBatch{SeqNum:5, ops:[{Put "a"β"1"}, {Delete "b"}]}
Replay(): applying the batch to the MemTable
// Replay applies all operations to mem using the batch's SeqNum.
func (b *WriteBatch) Replay(mem *lab02.MemTable) {
for _, op := range b.ops {
if op.del {
mem.Add(b.SeqNum, lab02.TypeDelete, op.key, nil)
} else {
mem.Add(b.SeqNum, lab02.TypeValue, op.key, op.val)
}
}
}
mem.Add calls EncodeInternalKey(userKey, seqNum, kt) and inserts the result
into the skip list. All ops in the batch share the same b.SeqNum β they all
become visible at the same snapshot point.
Replay is idempotent: calling Replay twice with the same batch inserts the
same internal keys twice. The skip listβs Put method checks for exact
duplicate internal keys and updates the value in place β so the second replay
produces the same state as the first. This makes WAL recovery safe even if the
process crashed mid-replay and the same record is replayed again on the next open.
The DB struct: field anatomy
type DB struct {
mem *lab02.MemTable // in-memory write buffer; all reads served from here
wal *lab01.WAL // append-only log; every write goes here first
seqNum uint64 // MVCC clock; monotonically increasing
dir string // directory path for WAL file
mu sync.Mutex // serialises all operations (see below)
}
seqNum: the MVCC clock
seqNum starts at 0 when the DB is first created. It increments by 1 after
every applyBatch call. Since each DB.Put or DB.Delete creates a
single-op batch, seqNum effectively increments by 1 per write.
DB.Get reads db.seqNum as the readSeq passed to MemTable.Get. This
means Get always reads the latest committed state β it uses the current
clock value, so it sees all writes up to and including the most recent one.
seqNum timeline:
Open(): seqNum = 0 (or recovered from WAL replay)
Put("k1", "v1"): seqNum 0 β 1
β MemTable: ("k1", seq=0, TypeValue) = "v1"
Put("k1", "v2"): seqNum 1 β 2
β MemTable: ("k1", seq=1, TypeValue) = "v2"
Get("k1"): readSeq = db.seqNum = 2
β MemTable.Get("k1", 2) finds ("k1", seq=1) β "v2" β
sync.Mutex: what it protects
The DB.mu mutex serialises all read and write operations. Every exported
method acquires and releases it:
func (db *DB) Put(key, value []byte) error {
db.mu.Lock()
defer db.mu.Unlock()
// ... applyBatch ...
}
func (db *DB) Get(key []byte) ([]byte, bool) {
db.mu.Lock()
defer db.mu.Unlock()
return db.mem.Get(key, db.seqNum)
}
Why reads need the lock too?
Consider Get reading db.seqNum while a concurrent Put is executing
applyBatch:
Goroutine A (Put): Goroutine B (Get):
b.Replay(db.mem) β MemTable partially updated
db.mem.Get(key, db.seqNum) β races here
db.seqNum++
Without the lock, Goroutine B could read a seqNum that has already advanced
past the partially-applied batch, or read a seqNum that is about to advance β
either way, it may observe an inconsistent snapshot.
The mutex ensures Get either sees the state before a write (if it acquires
the lock before the write) or after (if the write completed first). There is
no intermediate state.
TOCTOU without the lock (time-of-check-to-time-of-use):
Without locking:
T1: Goroutine B reads db.seqNum = 5
T2: Goroutine A completes Put, db.seqNum β 6, new key in MemTable
T3: Goroutine B calls db.mem.Get(key, 5) β stale seqNum, may miss new key
With the lock: Goroutine B either runs entirely before T2 (seqNum=5, correct snapshot) or entirely after (seqNum=6, also a correct snapshot).
Limitation: this design allows zero concurrency. Every Get blocks on
every concurrent Put. Production engines use a sync.RWMutex for reads
(multiple concurrent readers, exclusive writers) or a lock-free skip list
(lab 02 concurrent section) to allow parallel reads.
DB.Put execution trace: end to end
func (db *DB) Put(key, value []byte) error {
db.mu.Lock() // acquire exclusive lock
defer db.mu.Unlock() // release on return
b := &WriteBatch{SeqNum: db.seqNum} // assign current seqNum to batch
b.Put(key, value) // enqueue single op
return db.applyBatch(b) // WAL + MemTable + seqNum++
}
// applyBatch is the core write path, called under db.mu.
func (db *DB) applyBatch(b *WriteBatch) error {
encoded := b.Encode() // serialise to bytes (no I/O)
if err := db.wal.Append(encoded); err != nil {
return fmt.Errorf("db wal append: %w", err) // WAL error: abort
}
// WAL record is durable (fdatasync completed inside wal.Append)
b.Replay(db.mem) // update MemTable
db.seqNum++ // advance MVCC clock
return nil
}
Full call stack for db.Put([]byte("name"), []byte("Alice")):
db.Put("name", "Alice")
ββ db.mu.Lock()
ββ WriteBatch{SeqNum: 0}
ββ b.Put("name", "Alice") β ops = [{del:false, key:"name", val:"Alice"}]
ββ db.applyBatch(b)
ββ b.Encode()
β buf[0:8] = 00 00 00 00 00 00 00 00 (seqNum=0)
β buf[8:12] = 01 00 00 00 (count=1)
β buf[12] = 70 (type='p')
β buf[13:17]= 04 00 00 00 (kLen=4)
β buf[17:21]= 6e 61 6d 65 (key="name")
β buf[21:25]= 05 00 00 00 (vLen=5)
β buf[25:30]= 41 6c 69 63 65 (val="Alice")
β returns 30-byte slice
ββ db.wal.Append(encoded)
ββ lab01.WAL: write 7-byte header (type + length + CRC16)
ββ pwrite(fd, encoded, 30 bytes)
ββ fdatasync(fd) β blocks until durable on disk
ββ b.Replay(db.mem)
ββ mem.Add(0, TypeValue, "name", "Alice")
ββ ikey = EncodeInternalKey("name", 0, TypeValue)
= [6e 61 6d 65 01 00 00 00 00 00 00 00]
(name tag: seqNum=0, type=Value)
ββ sl.Put(ikey, "Alice") β skip list insert
ββ db.seqNum++ β 1
ββ db.mu.Unlock()
ββ return nil β caller acked; write is durable
recoverFromWAL: how the MemTable is rebuilt
func (db *DB) recoverFromWAL(path string) error {
// lab01.Recover reads all CRC-valid records from the WAL file.
// It stops at the first record with a bad CRC (partial write at crash).
records, err := lab01.Recover(path)
if err != nil {
return err // I/O error reading the file itself
}
for _, rec := range records {
// Decode the raw bytes back into a WriteBatch:
b, err := Decode(rec)
if err != nil {
// Corrupt batch payload (shouldn't happen if CRC passed, but defensive):
continue
}
// Re-apply every op to the fresh MemTable:
b.Replay(db.mem)
// Track the highest seqNum seen so far:
if b.SeqNum >= db.seqNum {
db.seqNum = b.SeqNum + 1 // resume from after the last committed batch
}
}
return nil
}
seqNum reconstruction during recovery
After crash, db.seqNum must resume from where it left off β not from 0. If
it started from 0, a new write at seqNum=0 would conflict with the
already-replayed data in the MemTable.
The loop if b.SeqNum >= db.seqNum { db.seqNum = b.SeqNum + 1 } finds the
maximum seqNum in all replayed batches and sets the clock to one past it.
Example:
WAL contains 5 records with SeqNums: 0, 1, 2, 3, 4
After recovery loop:
After record 0: db.seqNum = 0+1 = 1
After record 1: db.seqNum = 1+1 = 2
After record 2: db.seqNum = 2+1 = 3
After record 3: db.seqNum = 3+1 = 4
After record 4: db.seqNum = 4+1 = 5
First new write after recovery uses seqNum=5 β no collision.
WAL framing from lab 01: what Recover actually reads
The lab-01 WAL wraps each payload in a 7-byte frame:
ββββββββββββ¬βββββββββββββββββββββββββββ¬βββββββββββ¬βββββββββββββββββββββ
β type (1B)β payload length (4B LE) β CRC (2B) β payload (N bytes) β
ββββββββββββ΄βββββββββββββββββββββββββββ΄βββββββββββ΄βββββββββββββββββββββ
lab01.Recover reads each frame, verifies the CRC over payload, and returns
the raw payload bytes if the CRC matches. On a partial write (crash during
pwrite), the frameβs length field may exceed available bytes, or the CRC will
not match β in either case, Recover stops and returns only the records that
were fully flushed.
On-disk layout for three Put calls:
WAL file after Put("name","Alice") + Put("city","London") + Put("lang","Go"):
Record 0: [frame header 7B] [26 bytes: seqNum=0, Put("name","Alice")]
Record 1: [frame header 7B] [27 bytes: seqNum=1, Put("city","London")]
Record 2: [frame header 7B] [22 bytes: seqNum=2, Put("lang","Go")]
Total: 3 Γ 7 + 26 + 27 + 22 = 96 bytes on disk
At process restart, lab01.Recover returns [][]byte with 3 entries:
the 26, 27, and 22-byte payloads. recoverFromWAL decodes each and replays
into a fresh MemTable.
Crash scenarios: byte-level analysis
| Crash point | WAL state on disk | Recovery |
|---|---|---|
During b.Encode() | No bytes written | Recover sees empty file; seqNum=0; fresh start |
During pwrite of frame header | 0β7 bytes of new frame | Frame length unreadable or zero; Recover stops before this frame |
During pwrite of payload | Frame header + partial payload | CRC check on partial payload fails; Recover stops at this record |
During fdatasync | Full record in kernel buffer | Depends on storage: if block device buffers are lost, same as mid-pwrite; if device has battery-backed cache (most NVMe), record survives |
After fdatasync, before Replay | Full record on disk | Recover finds and replays it; MemTable restored correctly |
After Replay, before seqNum++ | Full record on disk | Same as above; seqNum reconstructed from WAL |
After seqNum++, before return | All in-memory state consistent | No recovery action needed; normal close |
Power-loss behaviour on consumer SSDs: many consumer NVMe drives do NOT
honour fdatasync in their firmware and keep data in volatile DRAM buffers.
Under power failure without a capacitor, those writes are lost even after
fdatasync returned. Enterprise SSDs include power-loss protection (PLP)
capacitors. LevelDB/RocksDB document this and recommend testing with FALLOC
and crash injection tools like dm-log-writes.
Group commit: the missing optimisation
Our implementation flushes the WAL (calls fdatasync) once per write. An
fdatasync on NVMe takes ~100 Β΅s. At 1 write per flush:
Maximum single-threaded write throughput:
1 / 100 Β΅s = 10,000 writes/second
Real LevelDB/RocksDB use group commit: multiple concurrent writers share a
single fdatasync. The first writer to acquire the write lock becomes the
βleaderβ; subsequent writers join a queue. The leader:
- Collects all queued batches.
- Encodes them into a single WAL write.
- Calls
fdatasynconce. - Wakes all queued writers.
With 100 concurrent writers and group commit:
All 100 writes share 1 fdatasync = 100 Β΅s total
Per-write latency: ~100 Β΅s (same as before for each caller)
Aggregate throughput: 100 writes / 100 Β΅s = 1,000,000 writes/second (100Γ improvement)
Our single-threaded sync.Mutex design makes group commit trivially
inapplicable β there is only ever one writer at a time. Group commit is only
useful with concurrent writers fighting for the WAL. Lab 03 prioritises
correctness and clarity over throughput.
Running the tests
cd leveldb
go test ./lab03/... -v -count=1
Expected output:
=== RUN TestPutGet
--- PASS: TestPutGet (0.00s)
=== RUN TestCrashRecovery
--- PASS: TestCrashRecovery (0.00s)
=== RUN TestBatchWrite
--- PASS: TestBatchWrite (0.00s)
=== RUN TestDeleteAfterRecovery
--- PASS: TestDeleteAfterRecovery (0.00s)
PASS
ok github.com/10xdev/leveldb/lab03
TestCrashRecovery walkthrough
This test directly verifies the WAL-first guarantee:
func TestCrashRecovery(t *testing.T) {
dir := t.TempDir() // fresh temp directory
// Phase 1: write 5 keys and close (WAL flushed on Close).
db, _ := Open(dir)
for i := 0; i < 5; i++ {
db.Put([]byte(fmt.Sprintf("key%d", i)), []byte(fmt.Sprintf("val%d", i)))
// After each Put: WAL record durable on disk, MemTable updated.
}
db.Close() // flushes WAL file handle (POSIX close)
// Phase 2: reopen β simulates process restart after crash.
db2, _ := Open(dir)
// Open calls recoverFromWAL which replays all 5 WAL records.
// MemTable is rebuilt from scratch; seqNum resumes from 5.
for i := 0; i < 5; i++ {
v, ok := db2.Get([]byte(fmt.Sprintf("key%d", i)))
// MemTable.Get(key, readSeq=5) finds each key at seqNum 0..4.
if !ok { t.Errorf("key%d not found after recovery", i) }
}
}
The t.TempDir() directory is deleted by the test framework after the test
completes. Phase 2βs Open finds the WAL file written by Phase 1 and replays
it. The test does not actually crash the process β it simulates a clean close
followed by reopen, which is the same code path as crash recovery (the WAL is
replayed regardless).
TestBatchWrite walkthrough
b := &WriteBatch{}
b.Put([]byte("a"), []byte("1"))
b.Put([]byte("b"), []byte("2"))
b.Put([]byte("c"), []byte("3"))
db.Write(b)
db.Write assigns batch.SeqNum = db.seqNum (= 0) and calls applyBatch.
All three ops are encoded in a single WAL record and replayed with the same
seqNum. A reader at readSeq=0 sees all three simultaneously β they form one
atomic unit. If the process crashed between wal.Append returning and
Replay completing, recovery would re-apply all three or none.
Running the demo
go run ./lab03/demo
Expected output:
put name = Alice
put city = London
put lang = Go
closing (WAL flushed) β¦
reopening β¦
reading after recovery:
name = Alice
city = London
lang = Go
The demo writes three keys, closes (simulating a clean shutdown), reopens
(replaying the WAL), and reads the recovered keys. The WAL file is in the
temp directory created by os.MkdirTemp β deleted automatically on demo exit.
FoundationDB parallel
In FDB, a committed transaction is the cluster-level equivalent of our
WriteBatch.Encode() + wal.Append(). The transaction log (tlog processes)
is replicated across 3+ machines using a Paxos-like protocol. fdatasync is
replaced by βmajority of tlog replicas acknowledged the writeβ.
FDBβs CommitVersion is the direct equivalent of our seqNum:
- In our engine:
seqNumis auint64in theDBstruct, incremented underdb.muon each commit. - In FDB:
CommitVersionis assigned by the sequencer (a single elected process), ensuring total order across thousands of concurrent clients on dozens of machines.
FDBβs transaction log (tlog) is the direct equivalent of our WAL:
- Both are append-only.
- Both are written before the in-memory state is updated.
- Both are replayed on restart.
FDBβs storage server rebuilds its in-memory B-tree from the tlog on
restart β equivalent to our recoverFromWAL rebuilding the MemTable from
CURRENT.wal.
The key architectural difference: FDBβs tlog replication means that even if the machine running the sequencer loses power, another machine has the full log and can continue. Our single-node WAL has no such replication β if the disk fails, data is lost.
Lab 04 β SSTable (Sorted String Table)
- What this lab adds
- Concept: Immutable files and log-structured storage
- Concept: Varint encoding
- SSTable file layout
Builder: writing an SSTable- Byte-level example: building a 2-record SSTable
Reader: opening and searching an SSTableSSTIter: sequential scan- Read amplification: why a single SSTable isnβt enough
- Running the tests
- Running the demo
- FoundationDB parallel
What this lab adds
Labs 01β03 built a durable, crash-safe in-memory engine. The MemTable lives in RAM; when the process exits, all data is gone (only the WAL remains, and the WAL is replayed on reopen). This has a hard limit: the machineβs RAM.
Lab 04 introduces the SSTable: an on-disk file format that stores a sorted, immutable snapshot of a MemTable. Once an SSTable is written:
- All the data it contains is permanent β no WAL replay needed.
- The in-memory MemTable can be discarded.
- Reads can go directly to the SSTable file.
Lab 04 provides two types:
Builderβ writes an SSTable from a sorted stream of key-value records.Readerβ reads an existing SSTable, supporting point lookup (Get) and sequential scan (NewIterator).
Concept: Immutable files and log-structured storage
Traditional storage engines (e.g. B-trees) update data in place: writing a new value for a key overwrites the old valueβs disk location. This causes two structural problems:
- Random writes β each update seeks to a specific offset in the file. An HDD performs ~100 random writes/s; sequential writes run at GB/s.
- Complex crash recovery β a crash during an in-place overwrite leaves the page half-written. B-trees solve this with page-level write-ahead logs or shadow-paging, adding implementation complexity.
A log-structured approach eliminates both:
B-tree (in-place): LevelDB (log-structured):
Page 7: "apple"β"v1" MemTable β WAL (append)
update: "apple"β"v2" β random MemTable full β SSTable (sequential write)
seek + overwrite Old MemTable discarded
crash risk! SSTable is immutable: no crash risk
Every write is sequential, and files are never mutated after creation. Immutability also means files can be safely read by multiple goroutines without locking β the OS page cache handles caching automatically.
Concept: Varint encoding
Fixed-width integers waste space when values are small. A 4-byte uint32 uses
4 bytes even for the value 1. SSTable records use varint encoding:
Each byte: 7 bits of data + 1 MSB continuation flag
MSB = 1: more bytes follow
MSB = 0: last byte
Encoding examples:
Value 1: binary 0000001 β 0x01 (1 byte)
Value 127: binary 1111111 β 0x7F (1 byte)
Value 128: binary 10000000 β 0x80 0x01 (2 bytes)
ββββββββββββββββββββββββββββββββββββββββββ
β byte 0: 0x80 = 1_0000000 β
β MSB=1 (more bytes), 7 data bits = 0 β
β byte 1: 0x01 = 0_0000001 β
β MSB=0 (last), 7 data bits = 1 β
β value = (1 << 7) | 0 = 128 β
ββββββββββββββββββββββββββββββββββββββββββ
Value 1000: 1000 = 0b1111101000
low 7 bits: 1101000 = 104 β 0xE8 (with MSB=1: 0b11101000)
high 3 bits: 111 = 7 β 0x07 (with MSB=0: 0b00000111)
β 0xE8 0x07 (2 bytes)
Value 100,000: β 0xA0 0x8D 0x06 (3 bytes)
For key and value lengths (almost always < 128 bytes in practice), varint saves
3 bytes per record versus a fixed uint32. A 4 MiB MemTable with 40,000 keys
averaging 20-byte keys and 80-byte values saves 40,000 Γ 3 Γ 2 = 240 KB β
about 6% of the file size.
appendVarint / readVarint annotated
// appendVarint appends the varint encoding of v to buf and returns the result.
func appendVarint(buf []byte, v uint64) []byte {
for v >= 0x80 { // while more than 7 bits remain
buf = append(buf, byte(v)|0x80) // emit low 7 bits with MSB=1
v >>= 7 // shift out the 7 bits we just emitted
}
return append(buf, byte(v)) // emit final byte with MSB=0
}
// readVarint reads one varint from r.
func readVarint(r io.Reader) (uint64, error) {
var v uint64
var shift uint
for {
var b [1]byte
if _, err := io.ReadFull(r, b[:]); err != nil {
return 0, err
}
v |= uint64(b[0]&0x7f) << shift // extract 7 data bits, place at shift
if b[0]&0x80 == 0 { // MSB=0: last byte
return v, nil
}
shift += 7
if shift >= 64 { // overflow guard (varint > 9 bytes)
return 0, errors.New("sstable: varint overflow")
}
}
}
Byte-level trace: decode 0xE8 0x07 (= 1000)
byte 0: 0xE8 = 0b11101000
MSB=1 (more bytes)
7 data bits: 0b1101000 = 104
v = 104 << 0 = 104
shift β 7
byte 1: 0x07 = 0b00000111
MSB=0 (last byte)
7 data bits: 0b0000111 = 7
v |= 7 << 7 = 7 Γ 128 = 896
v = 104 + 896 = 1000 β
SSTable file layout
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DATA SECTION β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β record 0: varint(kLen) β ikey β varint(vLen) β value β β
β β record 1: varint(kLen) β ikey β varint(vLen) β value β β
β β β¦ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β INDEX SECTION (one entry per data record) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β entry 0: varint(kLen) β ikey β 8B LE file-offset-of-rec0 β β
β β entry 1: varint(kLen) β ikey β 8B LE file-offset-of-rec1 β β
β β β¦ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β FOOTER (exactly 16 bytes) β
β ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ β
β β indexOffset (8B LE) β magic = 0x000000001edb4b4f (8B) β β
β ββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Keys are internal keys from lab02: userKey || uint64(seqNum<<8 | keyType).
Why store an index at all? Without an index, finding a key requires scanning
every record from offset 0. For a 2 MiB SSTable with 100-byte average record
size, that is up to 20,000 reads. The in-memory index (one entry per record,
~24 bytes each for a 20-byte key) costs ~480 KB of RAM but reduces Get to a
binary search O(log N) + short linear scan.
Why is the index at the end, not the beginning? The Builder writes records
in one sequential pass. If the index were first, the builder would need to
either pre-allocate space for it (unknown size before writing) or do two passes.
Appending the index after all data records allows a single forward pass. The
16-byte footer at the very end tells the reader where the index starts.
The magic number 0x1edb4b4f is checked before parsing. A truncated or
corrupt file returns a descriptive error rather than silently returning wrong
data.
Builder: writing an SSTable
type indexEntry struct {
lastKey []byte
offset uint64 // byte offset of this record in the data section
}
type Builder struct {
f *os.File
index []indexEntry
dataOffset int64 // current write position in the file
}
Add(ikey, value []byte) annotated
func (b *Builder) Add(ikey, value []byte) error {
offset := b.dataOffset // save current position = offset of this record
var rec []byte
rec = appendVarint(rec, uint64(len(ikey))) // 1-3 bytes for key length
rec = append(rec, ikey...) // key bytes
rec = appendVarint(rec, uint64(len(value))) // 1-3 bytes for value length
rec = append(rec, value...) // value bytes
n, err := b.f.Write(rec) // single write syscall per record
if err != nil {
return err
}
b.dataOffset += int64(n)
// Register this record in the index:
keyCopy := make([]byte, len(ikey))
copy(keyCopy, ikey) // own the key bytes β ikey may be reused by caller
b.index = append(b.index, indexEntry{lastKey: keyCopy, offset: uint64(offset)})
return nil
}
Keys must be added in CompareInternal ascending order β the caller
(MemTable flush) iterates the skip list which already yields keys in that order.
Finish() annotated
func (b *Builder) Finish() error {
indexOffset := b.dataOffset // index section begins immediately after data
// Write each index entry:
for _, e := range b.index {
var rec []byte
rec = appendVarint(rec, uint64(len(e.lastKey)))
rec = append(rec, e.lastKey...)
var offBuf [8]byte
binary.LittleEndian.PutUint64(offBuf[:], e.offset)
rec = append(rec, offBuf[:]...) // 8-byte LE file offset
if _, err := b.f.Write(rec); err != nil {
return err
}
}
// Write 16-byte footer:
var footer [16]byte
binary.LittleEndian.PutUint64(footer[0:8], uint64(indexOffset)) // index start
binary.LittleEndian.PutUint64(footer[8:16], magic) // 0x1edb4b4f
if _, err := b.f.Write(footer[:]); err != nil {
return err
}
return b.f.Sync() // fdatasync: ensure file is durable before returning
}
b.f.Sync() calls fdatasync β the same guarantee as the WAL. A crash after
Finish returns leaves a fully intact SSTable.
Byte-level example: building a 2-record SSTable
Records (in CompareInternal order):
ikey0 = EncodeInternalKey("apple", 3, TypeValue) = [61 70 70 6c 65 01 03 00 00 00 00 00 00] (13 bytes)
val0 = "green" = [67 72 65 65 6e] (5 bytes)
ikey1 = EncodeInternalKey("name", 1, TypeValue) = [6e 61 6d 65 01 01 00 00 00 00 00 00] (12 bytes)
val1 = "Alice" = [41 6c 69 63 65] (5 bytes)
Data section (after Builder.Add Γ 2):
offset 0: 0d β varint(13): kLen=13
offset 1: 61 70 70 6c 65 01 03 00 00 00 00 00 00 β ikey0 "apple" seq=3
offset 14: 05 β varint(5): vLen=5
offset 15: 67 72 65 65 6e β val0 "green"
offset 20: 0c β varint(12): kLen=12
offset 21: 6e 61 6d 65 01 01 00 00 00 00 00 00 β ikey1 "name" seq=1
offset 33: 05 β varint(5): vLen=5
offset 34: 41 6c 69 63 65 β val1 "Alice"
dataOffset = 39
Index section (starting at offset 39):
entry 0 (points to data offset 0):
0d β varint(13)
61 70 70 6c 65 01 03 00 00 00 00 00 00 β key copy
00 00 00 00 00 00 00 00 β offset=0 (LE uint64)
entry 1 (points to data offset 20):
0c β varint(12)
6e 61 6d 65 01 01 00 00 00 00 00 00 β key copy
14 00 00 00 00 00 00 00 β offset=20 (0x14)
Footer (last 16 bytes):
27 00 00 00 00 00 00 00 β indexOffset = 39 (0x27)
4f 4b db 1e 00 00 00 00 β magic = 0x1edb4b4f
Reader: opening and searching an SSTable
Open(path) annotated
func Open(path string) (*Reader, error) {
f, err := os.Open(path)
// ...
// 1. Read 16-byte footer from the end of the file:
var footer [16]byte
f.ReadAt(footer[:], info.Size()-16)
idxOffset := int64(binary.LittleEndian.Uint64(footer[0:8]))
if binary.LittleEndian.Uint64(footer[8:16]) != magic {
return nil, fmt.Errorf("sstable %s: bad magic", path) // corrupt file
}
// 2. Seek to index section and read all entries into memory:
f.Seek(idxOffset, io.SeekStart)
r := &Reader{f: f, indexOffset: idxOffset}
for pos < info.Size()-16 { // stop at footer
klen, _ := readVarint(f)
key := make([]byte, klen)
io.ReadFull(f, key)
var offBuf [8]byte
io.ReadFull(f, offBuf[:])
offset := binary.LittleEndian.Uint64(offBuf[:])
r.index = append(r.index, indexEntry{lastKey: key, offset: offset})
}
return r, nil
}
Why load the entire index into memory? The index is small relative to the
data section. For a 2 MiB SSTable with 100-byte average records, there are
~20,000 records. Each index entry is varint(kLen) + key + 8B β 30 bytes for
a 20-byte key. Total index size: 20,000 Γ 30 = 600 KB. At most a few percent
of RAM even with many SSTables open simultaneously. The payoff: all subsequent
Get calls are binary searches in RAM with no index-section I/O.
Get(ikey) annotated
func (r *Reader) Get(ikey []byte) ([]byte, bool) {
// Phase 1: binary search the in-memory index.
// Goal: find the first index entry whose key >= ikey.
lo, hi := 0, len(r.index)-1
for lo < hi {
mid := (lo + hi) / 2
if lab02.CompareInternal(r.index[mid].lastKey, ikey) < 0 {
lo = mid + 1 // index[mid] < ikey: answer must be to the right
} else {
hi = mid // index[mid] >= ikey: answer is here or to the left
}
}
// Phase 2: seek to data[lo].offset and linear-scan forward.
r.f.Seek(int64(r.index[lo].offset), io.SeekStart)
for pos < r.indexOffset { // don't read past data section
klen, _ := readVarint(r.f)
k := make([]byte, klen)
io.ReadFull(r.f, k)
vlen, _ := readVarint(r.f)
v := make([]byte, vlen)
io.ReadFull(r.f, v)
cmp := lab02.CompareInternal(k, ikey)
if cmp == 0 {
return v, true // exact hit
}
if cmp > 0 {
// k > ikey: check if same userKey (MVCC case β see below)
uk, _, _ := lab02.DecodeInternalKey(k)
searchUK, _, _ := lab02.DecodeInternalKey(ikey)
if bytes.Equal(uk, searchUK) {
return v, true // same userKey, lower seqNum β valid MVCC match
}
break // past the target userKey
}
}
return nil, false
}
Why the binary search works here
CompareInternal sorts by (userKey ASC, seqNum DESC). All versions of a
user key are contiguous in the data section and come before all versions of the
next user key. The binary search finds the first index entry whose key β₯ the
lookup key, which is the entry closest to β but not past β the target. The
linear scan from there reaches the target within a few records.
MVCC-aware lookup
A lookup at readSeq=R builds the lookup key as
EncodeInternalKey(uk, R, TypeValue). The file contains entries at various
seqNums. Due to the descending seqNum sort, the first matching user key entry
in the file is the most recent version β€ R β exactly the MVCC-correct answer.
File contents for key "apple" (seqNum descending):
("apple", seq=10, TypeValue, "green")
("apple", seq=5, TypeValue, "red")
("apple", seq=2, TypeValue, "blue")
Lookup ikey = EncodeInternalKey("apple", 7, TypeValue):
Binary search β points to entry for seq=10 (first "apple" entry β₯ our key)
Linear scan:
rec: ("apple", seq=10) β CompareInternal returns < 0 (10 > 7 in the tag)
Wait: seqNum DESC means lower tag = higher seqNum.
Actually CompareInternal(stored, ikey) > 0 means stored seqNum < ikey seqNum.
The first stored entry with seqNum β€ 7 is seq=5.
rec: ("apple", seq=5) β userKeys match, seqNum 5 β€ 7 β return "red" β
Callers must also handle TypeDelete results: if the returned record is a
tombstone, the key does not exist at readSeq and the caller must not fall
through to older SSTable levels.
SSTIter: sequential scan
func (r *Reader) NewIterator() *SSTIter {
it := &SSTIter{r: r}
r.f.Seek(0, io.SeekStart) // start at beginning of data section
it.advance() // prime the first record
return it
}
func (it *SSTIter) advance() {
pos, _ := it.r.f.Seek(0, io.SeekCurrent)
if pos >= it.r.indexOffset { // past data section β done
it.done = true
return
}
klen, _ := readVarint(it.r.f)
k := make([]byte, klen); io.ReadFull(it.r.f, k)
vlen, _ := readVarint(it.r.f)
v := make([]byte, vlen); io.ReadFull(it.r.f, v)
it.key = k; it.val = v
}
func (it *SSTIter) Valid() bool { return !it.done }
func (it *SSTIter) Key() []byte { return it.key }
func (it *SSTIter) Value() []byte { return it.val }
func (it *SSTIter) Next() { it.advance() }
The iterator maintains a file cursor β each call to Next() advances one
record. This is the interface used by:
- Lab 06 compaction (
MergedIteratormerges multipleSSTIters). - Lab 08 full-database scan (
NewIterator).
Read amplification: why a single SSTable isnβt enough
After a MemTable flush, Get("k") checks:
- Active MemTable (RAM)
- Immutable MemTable, if flushing (RAM)
- L0 SSTable (disk)
But after 4 flushes, there are 4 L0 files, each potentially containing the key.
The read path must check all of them (newest first) β this is read
amplification: one logical Get becomes up to K file reads.
Lab 06 compaction solves this by merging L0 files into L1 with non-overlapping key ranges, bounding L1 reads to exactly 1 file per key lookup.
Running the tests
cd leveldb
go test ./lab04/... -v -count=1
Expected output:
=== RUN TestBuildAndRead
--- PASS: TestBuildAndRead (0.00s)
=== RUN TestGetMissing
--- PASS: TestGetMissing (0.00s)
=== RUN TestIteratorOrder
--- PASS: TestIteratorOrder (0.00s)
=== RUN TestCorruptFooter
--- PASS: TestCorruptFooter (0.00s)
PASS
ok github.com/10xdev/leveldb/lab04
TestBuildAndRead walkthrough
Writes N records with Builder.Add, calls Finish, then opens a Reader and
calls Get for each key. Also calls NewIterator and verifies keys come back
in ascending order. This exercises both the binary-search path and the
sequential scan.
TestCorruptFooter
Writes a valid SSTable, then overwrites the last 16 bytes with garbage. Asserts
that Open returns a "bad magic" error. This verifies the corruption
detection before any data is parsed.
Running the demo
go run ./lab04/demo
Expected output:
built SSTable: 3 records, size=NNN bytes
get apple β green
get name β Alice
get lang β Go
iterate (sorted order):
apple = green
lang = Go
name = Alice
FoundationDB parallel
In FDB, the equivalent persistent unit is a B-tree page inside the storage
server. Pages are copy-on-write (immutable once written), and the storage server
keeps an in-memory page cache indexing page IDs to disk offsets β exactly our
[]indexEntry. Our SSTable is a radically simplified single-level B-tree where
the entire file is one βpageβ and the index covers every record.
Real LevelDB and RocksDB split SSTables into 4 KiB data blocks with one index entry per block β rather than one per record. This reduces index RAM usage by ~100Γ for large files while only increasing the scan length from 1 record to at most one block (40β80 records). They also add a Bloom filter per SSTable: a 10-bit/key probabilistic bit-array answering βis key X definitely NOT in this file?β in O(1) without any disk I/O. With a 1% false-positive rate this eliminates ~99% of unnecessary file reads for missing keys.
Lab 05 β MemTable Flush to L0
- What this lab adds
- Concept: Double-buffering (active + immutable MemTable)
- Concept: WAL rotation
- Concept: Atomic file rename
- Manifest format
- Read path: mem β imm β L0 (newest first)
maybeFlushannotatedflushImmutableannotated- Running the tests
- Running the demo
- FoundationDB parallel
What this lab adds
Labs 01β04 can now write durable SSTables. Lab 05 wires the flush trigger into the write path, making the engine self-managing:
- Each
Put/Deleteappends to the WAL and updates the active MemTable. - After every write,
maybeFlush()checksmem.ApproximateSize(). - When the active MemTable exceeds FlushThreshold (4 MiB), it is frozen
into the βimmutableβ MemTable (
db.imm), a fresh MemTable and new WAL segment take over all new writes, and a background goroutine writesimmto a new L0 SSTable. - After the flush completes, the SSTable path is written to the
MANIFEST(atomically via rename),immis set tonil, and the old WAL segment is deleted β its data is now safely persisted in the SSTable.
After Close, all acknowledged data is on disk in SSTables. On Open, only
the current CURRENT.wal is replayed; SSTables are read directly.
Concept: Double-buffering (active + immutable MemTable)
Flushing 4 MiB to an NVMe drive takes ~4 ms. Flushing to an HDD can take hundreds of milliseconds. If writes had to pause while the flush ran, the throughput would drop to zero for the duration of every flush cycle.
Double-buffering eliminates this stall by maintaining two MemTable slots:
Normal state (no flush in progress):
db.mem = active MemTable βββ all writes land here
db.imm = nil
Flush triggered (mem.ApproximateSize() >= 4 MiB):
db.imm = old db.mem βββ frozen; background goroutine drains this
db.mem = new empty MemTable βββ writes continue here immediately
db.wal = new WAL segment βββ new writes go to CURRENT.wal
Background flush completes:
SSTable written to 000003.sst
MANIFEST updated
db.imm = nil βββ released
old WAL segment deleted
The write path holds db.mu only for the pointer swap (microseconds), not
for the flush I/O (milliseconds). The background goroutine holds no lock
during disk I/O; it acquires the lock only to update db.imm and db.manifest.
Write stall: when double-buffering is insufficient
If db.mem fills again before db.imm is flushed, maybeFlush() finds
db.imm != nil and does nothing β the write proceeds, but db.mem can
grow unboundedly. In production engines, writes are stalled (blocked)
until the flush completes. Our implementation skips the stall for clarity:
func (db *DB) maybeFlush() {
if db.imm != nil || db.mem.ApproximateSize() < FlushThreshold {
return // imm != nil: flush in progress, do not freeze again yet
}
// ... freeze and launch background flush ...
}
RocksDBβs WriteStallCondition is triggered at 2 Γ FlushThreshold and
blocks the caller until the flush completes.
Concept: WAL rotation
Each MemTable is associated with exactly one WAL segment. When the active MemTable is frozen:
Before freeze:
db.mem = MemTable A (contains writes at seqNum 100..199)
db.wal = CURRENT.wal (contains the WAL records for writes 100..199)
During freeze (under db.mu):
1. db.imm = MemTable A (hand off to background flush)
2. db.mem = new MemTable B (empty; will receive writes 200+)
3. Close CURRENT.wal
4. Rename CURRENT.wal β 000003.wal (numbered = belongs to MemTable A)
5. Open new CURRENT.wal (for MemTable B's writes)
After flush completes:
000003.sst written (contains MemTable A's data, durable)
000003.wal deleted (MemTable A's data is in the SSTable; WAL no longer needed)
This bounds WAL disk usage: at most one numbered WAL (being flushed) plus the
current CURRENT.wal exist simultaneously. Total WAL disk usage β€ 2 Γ
FlushThreshold = 8 MiB.
WAL segment lifecycle and crash safety
| Crash at | WAL segments on disk | MANIFEST | Recovery |
|---|---|---|---|
| Mid-freeze (step 3β5) | CURRENT.wal + partial rename | Old manifest | Replay CURRENT.wal; MemTable rebuilt |
| After freeze, before SST write | CURRENT.wal + 000003.wal | Old manifest | Replay both WALs; no data loss |
| After SST write, before manifest update | CURRENT.wal + 000003.wal + 000003.sst | Old manifest | Replay both WALs; SST is ignored (not in manifest) |
| After manifest update, before WAL delete | CURRENT.wal + 000003.wal | New manifest (has 000003.sst) | Replay CURRENT.wal; 000003.wal is stale but safe to keep |
| After WAL delete | CURRENT.wal | New manifest | Normal reopen |
Our simplified recovery only replays CURRENT.wal. Full recovery from
numbered WALs is left as an exercise (lab 08 includes it).
Concept: Atomic file rename
The MANIFEST records which SSTable files exist. If the manifest were written in place and the process crashed mid-write, the manifest would be partially updated β some SSTable entries might be missing or corrupt.
POSIX rename(src, dst) is atomic with respect to crashes: any observer
(including a crash recovery process) sees either the old file at dst or the
new file at dst β never a partially-written intermediate.
func (m *Manifest) Save() error {
data, _ := json.MarshalIndent(m, "", " ")
// 1. Write new content to a temporary file:
tmp := m.dir + "/MANIFEST.tmp"
os.WriteFile(tmp, data, 0o644)
// Crash here: MANIFEST.tmp may be partial; MANIFEST is still the old valid copy.
// 2. Rename atomically:
return os.Rename(tmp, m.dir+"/MANIFEST")
// After this returns: MANIFEST is the new file.
// Crash during rename: either old or new MANIFEST, never partial.
}
Why not just write to MANIFEST directly?
WRONG: os.WriteFile(dir+"/MANIFEST", data, 0644)
β If data is 1 KB and the OS writes in 512-byte chunks:
Chunk 1 written, crash β MANIFEST has 512 bytes of new data + rest of old data
β corrupt manifest β all SSTable paths lost β data loss
The write-to-tmp + rename pattern is used in LevelDB, RocksDB, PostgreSQL, and almost every production database for this exact reason.
Manifest format
{
"levels": [
["data/000003.sst", "data/000006.sst"], β L0: oldest first (newest = index -1)
[] β L1: empty until lab06
]
}
Written to MANIFEST.tmp then atomically renamed to MANIFEST. On Open,
Load reads this file to know which SSTs to open.
Read path: mem β imm β L0 (newest first)
func (db *DB) Get(key []byte) ([]byte, bool) {
db.mu.Lock()
mem := db.mem // snapshot pointer
imm := db.imm // snapshot pointer (may be nil)
l0 := db.manifest.Levels[0] // copy slice header
seq := db.seqNum
db.mu.Unlock() // release lock before any I/O
// 1. Check active MemTable (most recent writes):
if v, ok := mem.Get(key, seq); ok {
return v, true
}
// 2. Check immutable MemTable (being flushed; still has data not yet on disk):
if imm != nil {
if v, ok := imm.Get(key, seq); ok {
return v, true
}
}
// 3. Search L0 SSTables newest-first:
for i := len(l0) - 1; i >= 0; i-- {
r, _ := lab04.Open(l0[i])
ikey := lab02.EncodeInternalKey(key, seq, lab02.TypeValue)
v, ok := r.Get(ikey)
r.Close()
if ok {
_, _, kt := lab02.DecodeInternalKey(ikey)
if kt == lab02.TypeDelete { return nil, false }
return v, true
}
}
return nil, false
}
Why search L0 newest-first? L0 files are produced by independent flushes and may have overlapping key ranges. Two L0 files can both contain key βappleβ at different seqNums. The newest file (highest file number) was produced from the most recently flushed MemTable and contains the highest seqNums β so it wins.
If we searched oldest-first, we might return a stale value from an older file and miss the newer value in a later file.
Read amplification in L0
With 4 L0 files, a Get for a missing key opens, reads, and closes 4 SSTable
files β 4 disk seeks. With 16 L0 files, itβs 16 disk seeks. This is read
amplification: the number of files grows linearly with the number of flushes.
Lab 06 compaction controls this by merging L0 files into non-overlapping L1
files, reducing Get at L1 to exactly 1 file regardless of how many records
were written.
maybeFlush annotated
func (db *DB) maybeFlush() {
// Guard: don't trigger a second flush if one is already in progress.
if db.imm != nil || db.mem.ApproximateSize() < FlushThreshold {
return
}
// Freeze the current MemTable:
db.imm = db.mem
db.mem = lab02.NewMemTable() // fresh MemTable for new writes
// Rotate the WAL:
db.wal.Close()
walPath := fmt.Sprintf("%s/%06d.wal", db.dir, db.nextFileNum.Add(1))
if wal, err := lab01.Open(walPath); err == nil {
db.wal = wal // new writes go to this segment
}
// Launch the background flush goroutine:
imm := db.imm // capture pointer before releasing lock
db.flushWg.Add(1)
go func() {
defer db.flushWg.Done()
db.flushImmutable(imm)
}()
}
maybeFlush is always called under db.mu. The goroutine it spawns must
re-acquire db.mu when updating db.imm and db.manifest after the flush.
flushImmutable annotated
func (db *DB) flushImmutable(imm *lab02.MemTable) {
// 1. Assign a file number and build the SSTable:
num := db.nextFileNum.Add(1) // atomic increment, no lock needed
path := fmt.Sprintf("%s/%06d.sst", db.dir, num)
bld, _ := lab04.NewBuilder(path)
it := imm.NewIterator() // iterate all internal keys in sorted order
for ; it.Valid(); it.Next() {
bld.Add(it.Key(), it.Value())
}
bld.Finish() // writes index + footer + fdatasync
bld.Close()
// At this point: 000NNN.sst is durable on disk.
// 2. Update the manifest and release the immutable MemTable:
db.mu.Lock()
db.manifest.AddL0(path) // add SST path to L0 list
db.manifest.Save() // write MANIFEST.tmp β rename to MANIFEST
db.imm = nil // signal: flush complete, slot is free
db.mu.Unlock()
// db.mu not held during bld.Finish() β the slow I/O runs concurrently
// with ongoing writes to db.mem.
}
The critical insight: db.mu is held for the in-memory pointer swap (step 2)
but not during the multi-millisecond SSTable write (step 1). This ensures the
write path is never blocked waiting for disk I/O.
Running the tests
cd leveldb
go test ./lab05/... -v -count=1
Expected output:
=== RUN TestFlushTriggered
--- PASS: TestFlushTriggered (0.07s)
=== RUN TestReadAfterFlush
--- PASS: TestReadAfterFlush (0.06s)
=== RUN TestCrashAfterFlush
--- PASS: TestCrashAfterFlush (0.07s)
=== RUN TestManifestRecovery
--- PASS: TestManifestRecovery (0.07s)
PASS
ok github.com/10xdev/leveldb/lab05
TestFlushTriggered walkthrough
Writes enough data to exceed FlushThreshold (4 MiB). Calls db.Close()
which calls db.flushWg.Wait() to ensure the background goroutine has
completed. Then checks that db.manifest.Levels[0] is non-empty β at least
one L0 SSTable was created.
TestCrashAfterFlush
- Write data, flush (via
Close). - Delete
CURRENT.wal(simulate crash after WAL was truncated). - Reopen the DB.
- Verify all data is still readable from L0 SSTables.
This proves that data in a completed SSTable is durable even if the WAL is lost.
TestManifestRecovery
- Write 10 keys, close.
- Open again β MANIFEST lists the L0 SSTable.
- Read back all 10 keys.
- Verify they come from the SSTable, not a WAL (WAL is empty after close).
Running the demo
go run ./lab05/demo
Expected output:
writing 10000 records β¦
flush triggered (mem > 4 MiB) β background flush running
closed; reopened
read after reopen: key000000 = val000000 β
β¦
FoundationDB parallel
In FDB, the equivalent of our flush goroutine is the storage server applying mutations from the transaction log to its B-tree. The storage server runs continuously; our flush goroutine is a simplified version of the same concept β batching writes into an immutable snapshot and writing them to a durable file.
The key difference: FDBβs storage server applies writes incrementally as transactions commit (one page update per transaction), whereas our flush writes an entire 4 MiB MemTable at once. FDBβs approach has lower latency for the first read after a write; our approach has lower per-write overhead.
FDBβs equivalent of the MANIFEST is the coordinator state, recording the current cluster topology and which storage servers own which key ranges. Coordinator state is also updated atomically (via Paxos) and replicated across multiple coordinator processes for fault tolerance β a distributed version of our write-to-tmp + rename pattern.
Lab 06 β Compaction and K-way Merge
- What this lab adds
- Concept: The read-amplification problem
- Concept: K-way merge with a min-heap
nodeHeap: Go container/heap integrationMergedIterator: annotated implementation- Deduplication traced: 3 versions of βnameβ
- Tombstone traced: Delete(βcolorβ) after Put(βcolorβ,βredβ)
compact()execution trace- Space and write amplification analysis
- Running the tests
- Running the demo
- FoundationDB parallel
What this lab adds
Lab 05 continuously produces L0 SSTable files. Without compaction, Get must
search all of them, and disk usage grows without bound (old deleted keys are
never reclaimed). Lab 06 adds:
MergedIteratorβ a min-heapβbased K-way merge that combines multiple sorted iterators (MemTable and/or SSTable) into one globally-sorted, deduplicated, tombstone-respecting stream.compact()β triggered when L0 reaches 4 files: merges all L0 files + existing L1 files into new non-overlapping L1 SSTables, then atomically updates the MANIFEST.NewIterator()β full-database range scan via a MergedIterator over all live data.
Concept: The read-amplification problem
After 4 MemTable flushes, there are 4 L0 SSTables. Every Get("apple") must
open and search all 4 files β 4 random reads β because L0 files can have
overlapping key ranges:
L0 (newest β oldest):
000008.sst: contains apple@seq=30 ("yellow"), date@seq=29
000006.sst: contains apple@seq=20 ("green"), age@seq=19
000004.sst: contains apple@seq=10 ("red"), name@seq=9
000002.sst: contains apple@seq=1 ("blue"), zip@seq=1
The correct answer for Get("apple") is "yellow" (seq=30), found in the
newest L0 file. But the engine doesnβt know which file contains the answer, so
it searches all 4 β worst-case O(K) reads per Get.
Compaction solves this by merging all L0 files into a set of L1 files with non-overlapping key ranges:
L1 (after compaction, non-overlapping):
000010.sst: age, apple (one winner: "yellow" at seq=30)
000011.sst: date, name
000012.sst: zip
Now Get("apple") is a binary search over L1 file boundary keys β exactly
1 file to open β O(1) disk reads.
Concept: K-way merge with a min-heap
Merging K sorted sequences requires finding the global minimum at each step. Naively, comparing the current-front element of each sequence takes O(K) per step, giving O(NK) total for N elements. A min-heap reduces this to O(N log K):
Step 1: Push the first element of each of K sequences into the heap.
Heap size = K. Cost: O(K log K) to build.
Step 2: Repeat until all sequences are exhausted:
a. Pop the minimum element. Cost: O(log K).
b. Advance that sequence. Push its next element (if any). Cost: O(log K).
c. Process the popped element (deduplication, tombstone check).
Total: N steps Γ O(log K) = O(N log K).
For K=4 (our compaction trigger) and N=200,000 records (4 Γ 50K):
- O(N log K) = 200,000 Γ logβ(4) = 400,000 comparisons
- vs O(NK) = 200,000 Γ 4 = 800,000 comparisons
The heap halves the work at K=4 and the advantage grows as K increases.
nodeHeap: Go container/heap integration
Goβs container/heap package provides heap.Push, heap.Pop, and heap.Init
but delegates actual storage and comparisons to a user-supplied type that
implements the heap.Interface:
type heapNode struct {
src rawIter // the source iterator for this element
key []byte
value []byte
}
type nodeHeap []heapNode
// Len, Less, Swap: required by sort.Interface (embedded in heap.Interface)
func (h nodeHeap) Len() int { return len(h) }
func (h nodeHeap) Less(i, j int) bool {
return lab02.CompareInternal(h[i].key, h[j].key) < 0
}
func (h nodeHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
// Push: heap.Push calls this with the new element already appended.
func (h *nodeHeap) Push(x any) { *h = append(*h, x.(heapNode)) }
// Pop: heap.Pop calls this to remove the minimum (already at index len-1).
func (h *nodeHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
heap.Pop first swaps h[0] (the minimum) with h[len-1], calls down
to restore the heap property on the remaining n-1 elements, then calls our
Pop() to extract h[len-1]. The result is always the globally smallest
element across all K sequences β proved by the min-heap invariant:
h[i] β€ h[2i+1] and h[i] β€ h[2i+2] for all i.
MergedIterator: annotated implementation
type MergedIterator struct {
h nodeHeap
key, value []byte
valid bool
readSeq uint64 // MVCC visibility cutoff
lastUserKey []byte // for deduplication
}
func NewMergedIterator(sources []rawIter, readSeq uint64) *MergedIterator {
m := &MergedIterator{readSeq: readSeq}
// Seed the heap with the first element from each valid source:
for _, src := range sources {
if src.Valid() {
heap.Push(&m.h, heapNode{src: src, key: src.Key(), value: src.Value()})
}
}
m.next() // advance to the first visible record
return m
}
next() step-by-step
func (m *MergedIterator) next() {
for len(m.h) > 0 {
// 1. Pop the globally smallest internal key:
node := heap.Pop(&m.h).(heapNode)
// 2. Advance that source and re-push its next element:
node.src.Next()
if node.src.Valid() {
heap.Push(&m.h, heapNode{
src: node.src,
key: node.src.Key(),
value: node.src.Value(),
})
}
// 3. Extract seqNum and keyType from the last 8 bytes of the internal key:
ikey := node.key
tag := binary.LittleEndian.Uint64(ikey[len(ikey)-8:])
seqNum := tag >> 8
kt := keyType(tag & 0xff)
userKey := ikey[:len(ikey)-8]
// 4. Skip records beyond the snapshot horizon:
if seqNum > m.readSeq {
continue
}
// 5. Skip older versions of already-emitted user keys:
if bytes.Equal(userKey, m.lastUserKey) {
continue
}
// Record this user key so subsequent versions are skipped:
m.lastUserKey = append(m.lastUserKey[:0], userKey...)
// 6. Skip tombstones (the key was deleted at this seqNum):
if kt == typeDelete {
continue // tombstone suppresses all older versions too
}
// 7. Emit this record:
m.key = userKey
m.value = node.value
m.valid = true
return
}
m.valid = false // all sources exhausted
}
Deduplication proof
Because CompareInternal sorts by (userKey ASC, seqNum DESC), all versions
of βappleβ are adjacent in the merged stream, with the highest seqNum first.
When next() pops the first βappleβ entry (highest seqNum β€ readSeq), it
records lastUserKey = "apple". All subsequent βappleβ entries are popped
in order and hit the bytes.Equal(userKey, m.lastUserKey) check β skipped.
This ensures each user key is emitted at most once, with the latest visible version.
Tombstone proof
If Delete("k") was written at seqNum=50 and Put("k","v") was written at
seqNum=10, the merged stream yields:
("k", seq=50, TypeDelete, "")β popped first (50 > 10)
next() records lastUserKey = "k", then hits kt == typeDelete β continue.
The next iteration pops ("k", seq=10, TypeValue, "v"), but it hits the
lastUserKey == "k" guard β skipped. The iterator moves past all βkβ entries
without ever emitting a value β the deletion is correctly visible.
Deduplication traced: 3 versions of βnameβ
Sources (each is a sorted iterator):
src0 (MemTable): ("name", seq=30, TypeValue, "Charlie")
src1 (L0 file): ("name", seq=20, TypeValue, "Bob")
src2 (L0 file): ("name", seq=10, TypeValue, "Alice")
readSeq = 30
Initial heap:
[("name",30), ("name",20), ("name",10)]
After heapify: h[0] = ("name",30) is smallest (seqNum DESC β 30 = lowest tag)
Step 1:
Pop ("name",30): src0 exhausted; advance src0 β nothing to push.
seqNum=30 β€ readSeq=30: pass visibility check.
lastUserKey was "": not equal to "name": pass dedup check.
kt=TypeValue: pass tombstone check.
Emit: key="name", value="Charlie"
lastUserKey = "name"
Step 2:
Pop ("name",20): src1 advanced; no more elements from src1.
seqNum=20 β€ readSeq=30: pass visibility check.
lastUserKey = "name" == "name": SKIP (step 5)
Step 3:
Pop ("name",10): src2 advanced; no more elements.
lastUserKey = "name" == "name": SKIP
Heap empty. done.
Final output: key="name", value="Charlie" (only the newest version)
Tombstone traced: Delete(βcolorβ) after Put(βcolorβ,βredβ)
Sources:
src0: ("color", seq=25, TypeDelete, "")
src1: ("color", seq=15, TypeValue, "red")
readSeq = 30
Step 1:
Pop ("color",25): seqNum=25 β€ 30 β; lastUserKey="" β "color" β
kt=TypeDelete β continue (tombstone! suppress and advance)
lastUserKey = "color"
Step 2:
Pop ("color",15): seqNum=15 β€ 30 β; lastUserKey="color" == "color" β SKIP
Heap empty. MergedIterator sees no "color" record.
This is the tombstone cascade: the delete at seq=25 acts as a sentinel, and the dedup guard silently drops all older values in a single pass.
compact() execution trace
Triggered when len(Levels[0]) >= CompactL0Trigger (4 files):
func (db *DB) compact() {
// 1. Snapshot L0+L1 under lock:
db.mu.Lock()
l0 := append([]string{}, db.manifest.Levels[0]...)
l1 := append([]string{}, db.manifest.Levels[1]...)
readSeq := db.seqNum
db.mu.Unlock() // lock released; I/O runs concurrently with writes
allFiles := append(l0, l1...)
// 2. Open all readers:
var sources []rawIter
for _, path := range allFiles {
r, _ := lab04.Open(path)
sources = append(sources, &sstIterAdapter{r.NewIterator()})
}
// 3. K-way merge:
merged := NewMergedIterator(sources, readSeq)
// 4. Write new L1 files (split at L1SplitSize = 2 MiB):
var newL1 []string
var bld *lab04.Builder
var curSize int64
for merged.Valid() {
if bld == nil || curSize >= L1SplitSize {
// Close current builder (if any) and open a new one:
if bld != nil { bld.Finish(); bld.Close(); newL1 = append(newL1, curPath) }
curPath = fmt.Sprintf("%s/%06d.sst", db.dir, db.nextFileNum.Add(1))
bld, _ = lab04.NewBuilder(curPath)
curSize = 0
}
bld.Add(merged.Key(), merged.Value())
curSize += int64(len(merged.Key()) + len(merged.Value()))
merged.Next()
}
if bld != nil { bld.Finish(); bld.Close(); newL1 = append(newL1, curPath) }
// 5. Atomically update manifest:
db.mu.Lock()
db.manifest.replaceFiles(0, l0, nil) // clear L0
db.manifest.replaceFiles(1, l1, newL1) // replace old L1 with new L1
db.manifest.NextSeq = db.seqNum
db.manifest.Save()
db.mu.Unlock()
// 6. Delete old files:
for _, path := range allFiles { os.Remove(path) }
}
Why is I/O outside the lock? The SSTable write for 4 Γ 4 MiB = 16 MiB of
input data may take 16β100 ms on an HDD. Holding db.mu for that duration
would block every Put and Get for 100 ms β unacceptable. The merged stream
reads from files that are still referenced in the manifest (not yet deleted), so
reads are safe. New writes go to the new db.mem, unaffected by compaction.
Non-overlap guarantee: The merged stream yields keys in globally sorted order (proven by the heap invariant). The L1 files are written in the order keys appear in that stream. Each file starts where the previous one ended. Therefore, the key ranges of the resulting L1 files are non-overlapping and cover the entire compacted keyspace.
Space and write amplification analysis
Space amplification (during compaction)
At peak β while the new L1 files are being written but before the old L0 files are deleted β both sets exist simultaneously:
Before: L0 = 4 Γ 4 MiB = 16 MiB, L1 = varies
During: L0 (16 MiB) + new L1 being built (up to 16 MiB) = 32 MiB peak
After: L0 deleted, new L1 = ~16 MiB (deduplicated)
Peak space amplification β 2Γ. If the working set is W MiB, we need 2W MiB disk space to be safe during compaction.
Write amplification (per level)
Each value is written once to the MemTable (in-memory, βfreeβ), once to L0 during flush, and once to L1 during compaction. That is 2 disk writes per value. In a full multi-level engine (5 levels), a key might be rewritten at each level: write amplification β L (number of levels). For L=5 on RocksDB: WA β 10β30Γ depending on compaction strategy. Our 2-level engine achieves WA = 2Γ.
Proof that WA = 2 for our engine:
Write path: Put("k","v") at seqNum=N
1. WAL (lab01): 1 write (can amortize; WAL is sequential and large)
2. MemTable: in-memory, no disk write
3. Flush to L0: 1 disk write (the SSTable record)
4. Compaction L0βL1: 1 disk write (the new L1 SSTable record)
Old L0 record is deleted.
Total disk writes per logical Put: 2 (L0 + L1)
K-way merge complexity proof
For N total records across K sources and a binary heap of size K:
- Building the heap: O(K) by heapify, or O(K log K) by K insertions
- Per record: 1 pop + (0 or 1) pushes = 2 heap operations Γ O(log K) = O(log K)
- Total: O(N log K)
For N=200,000, K=5 (4 L0 + 1 L1 iterator):
- O(N log K) = 200,000 Γ logβ(5) β 200,000 Γ 2.32 β 464,000 operations
- O(N) = 200,000 (lower bound: must read every record)
- Overhead of the heap: ~2.3Γ the minimum, negligible in practice
Running the tests
cd leveldb
go test ./lab06/... -v -count=1
Expected output:
=== RUN TestMergedIterator
--- PASS: TestMergedIterator (0.00s)
=== RUN TestCompactionDeduplicates
--- PASS: TestCompactionDeduplicates (0.21s)
=== RUN TestTombstoneNotVisible
--- PASS: TestTombstoneNotVisible (0.08s)
=== RUN TestNewIterator
--- PASS: TestNewIterator (0.06s)
PASS
ok github.com/10xdev/leveldb/lab06
TestCompactionDeduplicates walkthrough
- Write 3 versions of βappleβ across 3 separate flushes (each flush > threshold).
- Wait for the 4th flush (or trigger manually) which causes compaction.
- After compaction,
Get("apple")returns only the newest version. - Open all L1 SSTable files directly and verify each user key appears exactly once β no duplicate versions survive in L1.
TestTombstoneNotVisible
Put("ghost","value")β flush.Delete("ghost")β flush.- Trigger compaction.
Get("ghost")returns(nil, false).- Scan the L1 files directly β βghostβ appears 0 times (both the value and tombstone were dropped by the compactionβs merged iterator).
Running the demo
go run ./lab06/demo
Expected output:
wrote 8000 records across multiple flushes
L0 files after flush: 4
compaction triggeredβ¦
L1 files after compaction: 4
L0 files after compaction: 0
iterating all recordsβ¦
records seen: 8000 (0 duplicates)
FoundationDB parallel
FDBβs compaction equivalent is the storage serverβs data distribution and shard splits/merges. When a shard (key range) grows beyond a target size (~100 MB in production), the data distributor splits it into two shards, each with non-overlapping key ranges β exactly the L1 non-overlap guarantee we achieve via K-way merge + sequential write.
FDB does not use a leveled compaction tree like LevelDB. Instead, it uses a B-tree (or in newer FDB versions, a Redwood B+-tree) within each storage server, with copy-on-write pages. The equivalent of our K-way merge happens during background page compaction when multiple versions of a key exist on the same page: old MVCC versions are garbage-collected, similar to our tombstone suppression.
The K-way merge algorithm itself appears in FDBβs transaction log recovery:
each log server holds a prefix of the write history; recovery reads from all
log servers simultaneously (K sources) and merges them in seqNum order β
exactly NewMergedIterator(sources, maxReadSeq).
Lab 07 β Snapshots and Benchmarks
- What this lab adds
- The MVCC snapshot model
GetSnapshot()annotatedSnapshot.Get()execution trace- Isolation anomaly table
- Read-your-writes guarantee
- seqNum lifecycle: end-to-end
Snapshot.NewIterator()annotated- Benchmark analysis
- Running the tests
- Running the demo
- FoundationDB parallel
What this lab adds
Lab 06 is a complete compacting key-value store. Lab 07 adds the final user-visible feature: snapshot isolation.
A snapshot captures the database state at a single seqNum and holds it
constant regardless of subsequent writes. All reads through the snapshot
see exactly the key-value pairs that existed at snapshot creation time.
This is the foundation for multi-key transactions, consistent backups, and
range scans that cannot be affected by concurrent writers.
The lab also includes benchmarks that demonstrate how the engine performs under sustained write load.
The MVCC snapshot model
Recall from lab02: every Put/Delete writes an internal key:
internal key = userKey || uint64(seqNum<<8 | keyType)
The seqNum is a monotonically increasing counter, incremented for each write.
The MemTable and SSTables store all versions of every key. No value is ever
overwritten in place.
A snapshot pins a readSeq. Every Get and NewIterator call that goes
through the snapshot uses this fixed readSeq instead of the live db.seqNum:
Write sequence:
seqNum=1: Put("color","red")
seqNum=2: GetSnapshot() β snap.seqNum = 1
seqNum=3: Put("color","blue")
snap.Get("color") β searches for versions with seqNum β€ 1 β "red"
db.Get("color") β searches for versions with seqNum β€ 3 β "blue"
Because MVCC stores all versions, both reads are correct simultaneously β the snapshot reader and the live reader see different values of βcolorβ with no locking.
GetSnapshot() annotated
func (db *DB) GetSnapshot() *Snapshot {
seq := db.inner.SeqNum() // returns db.seqNum (next-to-assign)
if seq > 0 {
seq-- // convert to last-committed seqNum
}
return &Snapshot{db: db, seqNum: seq}
}
Why seq - 1?
db.seqNum is the next seqNum to be assigned β it has not been written yet.
The last committed write was db.seqNum - 1. If we used db.seqNum as the
snapshotβs readSeq, the snapshot would be trying to see a write that hasnβt
happened yet (and may never happen if the write fails).
Timeline:
seqNum=5: Put("k","v5") written and durable
db.seqNum is now 6 (next-to-assign)
GetSnapshot():
seq = db.SeqNum() = 6
seq-- = 5 β correct: last committed write
snap.seqNum = 5
snap.Get("k") β searches for versions with seqNum β€ 5 β finds "v5" β
If we had used 6: searches for seqNum β€ 6 β same result.
But at seqNum=6 the entry does not exist, so the filter would still work.
The reason for seq-- is conceptual correctness and for the edge case when
seqNum=0: seq-- would underflow to MaxUint64, returning every possible entry.
The guard `if seq > 0 { seq-- }` prevents this.
The seqNum=0 edge case: Open starts with seqNum=1 (or higher if
MANIFEST has a stored NextSeq). For a fresh empty database: seqNum=1,
GetSnapshot() returns snap.seqNum=0. A snap.Get with readSeq=0 finds
no records (all stored records have seqNum β₯ 1) β correctly representing an
empty snapshot.
Snapshot.Get() execution trace
type Snapshot struct {
db *DB
seqNum uint64
}
func (s *Snapshot) Get(key []byte) ([]byte, bool) {
return s.db.inner.GetAt(key, s.seqNum)
}
GetAt is the same as Get but uses the provided readSeq instead of
db.seqNum:
func (db *DB) GetAt(key []byte, readSeq uint64) ([]byte, bool) {
// Build the MVCC lookup key:
ikey := lab02.EncodeInternalKey(key, readSeq, lab02.TypeValue)
// EncodeInternalKey("color", 1, TypeValue) β
// [63 6f 6c 6f 72 01 01 00 00 00 00 00 00]
// c o l o r ^--keyType=1 (TypeValue), seqNum=1
// Check mem, imm, L0, L1 β same as Get() but with fixed ikey seqNum:
if v, ok := db.mem.Get(key, readSeq); ok { return v, true }
// MemTable.Get calls CompareInternal(ikey_from_mem, ikey) β returns the
// first version with seqNum β€ readSeq.
// ... imm, L0, L1 same pattern ...
}
Full call stack for snap.Get("color") (snap.seqNum=1, color was written at seq=1):
snap.Get("color")
β db.inner.GetAt("color", 1)
β ikey = EncodeInternalKey("color", 1, TypeValue)
β mem.Get("color", 1)
skip list search for ikey
finds entry ("color", seq=3) first (if it exists) β seqNum=3 > 1 β skip
finds entry ("color", seq=1) β seqNum=1 β€ 1 β return "red" β
If βcolor@seq=1β is not in mem (was flushed to L0/L1), the same ikey is sent
to lab04.Reader.Get(ikey) which performs the binary-search + linear scan
described in lab04.
Isolation anomaly table
| Anomaly | Description | Prevented by MVCC? |
|---|---|---|
| Dirty read | Read uncommitted data from an in-progress write | β Yes β uncommitted writes have no seqNum yet |
| Non-repeatable read | Read same key twice, get different values | β Yes β snapshot seqNum is fixed |
| Phantom read | Range scan sees different rows on second scan | β Yes β snapshot iterates fixed seqNum |
| Write-write conflict | Two writers overwrite each other | β No β lab07 has no write transactions |
| Lost update | Read-modify-write not atomic | β No β lab07 has no compare-and-swap |
MVCC (snapshot isolation) eliminates read anomalies without locks. It does not protect concurrent writers from each other β that requires either pessimistic locking (lock the key before reading it) or optimistic concurrency control (check at commit time that no conflicting write happened). FDB uses the latter via its conflict-detection layer on top of MVCC.
Dirty read prevention: proof
A write at seqNum=N is added to the MemTable under db.mu, then seqNum
is incremented to N+1. GetSnapshot() calls db.SeqNum() which returns the
current db.seqNum. At the moment of the snapshot, either:
- The write at
Nis complete:db.seqNum = N+1, snapshot seesreadSeq=N, which includes the write atN. β - The write at
Nis not yet started:db.seqNum = N, snapshot seesreadSeq = N-1, which does not include the incomplete write. β - The write is in progress (between WAL append and
seqNum++): the write holdsdb.muduringseqNum++;GetSnapshot()also callsdb.SeqNum()which returnsdb.seqNumunder no lock β but the seqNum is an atomic read in lab08, or reads the committed increment value in labs 05-07. In either case, the snapshot sees eitherN(before the increment) orN+1(after), never a partial state.
Read-your-writes guarantee
After Put("k","v") at seqNum=N, db.seqNum is now N+1. A subsequent
db.Get("k") uses readSeq = db.seqNum - 1 = N. The MemTable contains the
entry ("k", N, TypeValue, "v"). MemTable.Get("k", N) finds this entry
(seqNum=N β€ readSeq=N) and returns "v". β
For a snapshot taken after the Put:
GetSnapshot()seesdb.seqNum = N+1, returnssnap.seqNum = N.snap.Get("k")usesreadSeq = Nβ same result. β
For a snapshot taken before the Put:
GetSnapshot()seesdb.seqNum = N, returnssnap.seqNum = N-1.snap.Get("k")usesreadSeq = N-1β does not see thePut. β This is correct: the write happened after the snapshot was created.
seqNum lifecycle: end-to-end
Open:
Load MANIFEST β manifest.NextSeq = 100 (last flushed seqNum)
db.seqNum = 100
WAL replay:
WAL record: seqNum=100, Put("x","v")
applyWALRecord: if 100 < manifest.NextSeq=100 β false (not skipped; equal)
mem.Add(100, TypeValue, "x", "v")
db.seqNum = 101 (max(current, rec.seqNum+1))
Writes after Open:
Put("y","w"):
seq=101; db.seqNum=102
WAL record written with seq=101
mem.Add(101, TypeValue, "y", "w")
Flush triggered:
flushImmutable: builds SSTable from mem (seqNums 100-101)
manifest.NextSeq = 102 β persisted
MANIFEST saved
Crash + reopen:
manifest.NextSeq = 102
db.seqNum = 102
WAL replay: record with seqNum=101 β 101 < 102 β SKIPPED
(already in SSTable; replaying it again would add duplicate β wrong)
Snapshot.NewIterator() annotated
func (s *Snapshot) NewIterator() *lab06.MergedIterator {
return s.db.inner.NewIteratorAt(s.seqNum)
}
NewIteratorAt(readSeq) creates a MergedIterator with readSeq=s.seqNum
instead of the live db.seqNum. All the dedup and tombstone logic from lab06
applies, but restricted to versions written at or before the snapshot.
This enables consistent range scans:
snap := db.GetSnapshot()
it := snap.NewIterator()
for it.Valid() {
fmt.Printf("%s = %s\n", it.Key(), it.Value())
it.Next()
}
// All key-value pairs are from the snapshot instant.
// Concurrent writes during this loop are completely invisible.
Benchmark analysis
The lab07 benchmarks measure raw write throughput:
cd leveldb
go test ./lab07/... -bench=. -benchtime=5s
Expected output (NVMe SSD, macOS):
BenchmarkPut-8 50000 28000 ns/op 35714 ops/s
BenchmarkGet-8 200000 6200 ns/op 161290 ops/s
Why 28 Β΅s per write?
Each Put involves:
1. db.mu.Lock() ~100 ns (uncontended mutex)
2. WAL encode (in-memory) ~200 ns
3. wal.Write(record) ~100 ns (buffered write)
4. wal.fdatasync() ~20 Β΅s β dominant cost on NVMe
5. mem.Add(key, value) ~500 ns (skip list insert)
6. db.mu.Unlock() ~100 ns
Total: ~21-25 Β΅s, matching the ~28 Β΅s observed
The fdatasync call is the bottleneck. On a macOS APFS SSD, fdatasync
(actually fcntl(F_FULLFSYNC) on macOS for true durability) takes 5β30 Β΅s.
On a mechanical HDD it would be 2β10 ms.
To increase write throughput, real engines use group commit: one goroutine
collects N concurrent writes into a single WAL buffer and issues a single
fdatasync for all of them. This amortizes the sync cost:
Without group commit: 10 writers Γ 20 Β΅s/sync = 200 Β΅s elapsed for 10 writes
With group commit: 10 writers batched β 1 Γ 20 Β΅s/sync = 2 Β΅s per write
RocksDBβs WriteBatch implements group commit. Lab 03βs WriteBatch is the
precursor; lab 08 adds the plumbing to amortize the sync cost.
Why 6 Β΅s per read?
Get acquires db.mu for a microsecond to snapshot the pointers, then reads
from the MemTable (O(log N) skip list search, all in L1 cache after warm-up)
or the OS page cache (L0/L1 SSTables). For a hot working set entirely in the
MemTable, the 6 Β΅s is dominated by the mutex and skip list traversal.
Running the tests
cd leveldb
go test ./lab07/... -v -count=1
Expected output:
=== RUN TestSnapshotIsolation
--- PASS: TestSnapshotIsolation (0.00s)
=== RUN TestSnapshotDoesNotSeeNewWrites
--- PASS: TestSnapshotDoesNotSeeNewWrites (0.00s)
=== RUN TestSnapshotIterator
--- PASS: TestSnapshotIterator (0.00s)
=== RUN TestReadYourWrites
--- PASS: TestReadYourWrites (0.00s)
PASS
ok github.com/10xdev/leveldb/lab07
TestSnapshotIsolation walkthrough
db.Put("k", "v1") // seqNum=1
snap := db.GetSnapshot() // snap.seqNum=1
db.Put("k", "v2") // seqNum=2
v, _ := snap.Get("k") // should return "v1"
assert(v == "v1") // β snapshot sees seqNum β€ 1
v, _ = db.Get("k") // should return "v2"
assert(v == "v2") // β live read sees seqNum β€ 2
TestSnapshotIterator walkthrough
- Write keys βaβ, βbβ, βcβ.
- Take snapshot S1.
- Write keys βdβ, βeβ and delete βbβ.
- Iterate S1 β sees βaβ, βbβ, βcβ (no βdβ, βeβ; βbβ not deleted).
- Iterate live DB β sees βaβ, βcβ, βdβ, βeβ (βbβ deleted).
Running the demo
go run ./lab07/demo
Expected output:
=== Scenario: snapshot isolation ===
before snapshot: color=red
snap.Get(color)=red β snapshot at seqNum=1
after db.Put(color,blue) at seqNum=2:
snap.Get(color)=red β unchanged β
db.Get(color)=blue β live read β
=== Benchmark: 5000 sequential writes ===
5000 writes in NNNms β NNN writes/s
=== Scenario: snapshot range scan ===
scan at snapshot sees N keys
scan at live DB sees N+K keys (K added after snapshot)
FoundationDB parallel
FDBβs snapshot isolation is built directly on the same MVCC model. Every FDB
transaction reads at a readVersion (FDBβs equivalent of our seqNum).
GetSnapshot() calls db->getReadVersion() which returns the current committed
version β exactly db.seqNum - 1 in our engine.
FDB extends the model further with strict serializability (snapshot isolation
- conflict detection): two transactions conflict if they read and write the same
key. The conflict resolver at the proxy checks read-write conflicts at commit
time, rejecting the transaction with a retryable error (
transaction_too_old) if the readVersion is stale. This gives users the ability to write read-modify-write transactions without programmer-visible locking.
Snapshot.Release() is a no-op in our implementation. In production engines
(RocksDB, FDB), Release() unregisters the snapshot from a global snapshot
list. The compaction process maintains minSnap = min(snap.seqNum across all live snapshots). MVCC versions older than minSnap that have been superseded
by newer versions can be safely garbage-collected during compaction β reducing
SSTable bloat from long-lived snapshots.
Lab 08 β Complete Storage Engine
- What this lab builds
- Architecture
- Thread safety model
- WAL record format (lab08-specific)
write()execution trace:Put("city","London")flushImmlock-release pattern annotatedcompact()lock-release pattern annotatedapplyWALRecord: crash recovery filter annotatedseqNum = 1reservation: proofbgErrpropagation- Running the 27 tests
- Running the demo
- Tuning constants
- FoundationDB parallel
What this lab builds
Lab 08 is a self-contained, complete storage engine β no imports from earlier labs. It re-implements every component (WAL, MemTable, skip list, SSTable, manifest, compaction) in a single package with production-quality thread safety, background error propagation, and API parity with the goleveldb interface.
All 7 subsystems are integrated:
| Subsystem | Source | From which lab |
|---|---|---|
| WAL (append-only log) | wal.go | lab01 pattern |
| MVCC internal key | key.go | lab02 pattern |
| Skip list MemTable | memtable.go | lab02 pattern |
| SSTable builder/reader | sst.go | lab04 pattern |
| Double-buffer flush | db.go:maybeFlushLocked | lab05 pattern |
| K-way merge iterator | iter.go | lab06 pattern |
| Snapshot isolation | db.go:GetSnapshot | lab07 pattern |
Architecture
Writes Reads
β β
ββββββββββΌβββββββββ ββββββββββΌβββββββββ
β sync.Mutex β β sync.Mutex β
β (acquire) β β (acquire) β
ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ
β β
ββββββββββΌβββββββββ βββββ snapshot mem/imm/l0/l1 pointers
β WAL.append(rec) β β ββββββββββββββββββββββββββββββββββ
β (fdatasync) β β (release lock; I/O runs free) β
ββββββββββ¬βββββββββ β β
β β ββββββββββββ β
ββββββββββΌβββββββββ β β MemTable β βββ check mem β
β MemTable.add β β ββββββββββββ β
β (skip list) β β ββββββββββββ β
ββββββββββ¬βββββββββ β β imm (opt)β βββ check imm β
β β ββββββββββββ β
maybeFlushLocked β ββββββββββββββββββββββββ β
(if mem β₯ 4 MiB): β β L0 SSTables (newest) β βββββββ
freeze mem β imm β β L1 SSTables β
spawn flushImm goroutineβββββββββββββββββββββββββββββββ
β
ββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββ
β Background goroutine: flushImm β
β newSSTBuilder β iterate imm β bld.finish() (fdatasync) β
β db.mu.Lock() β manifest.save() β db.imm=nil β
β if len(L0) β₯ 4: spawn compact goroutine β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββ
β Background goroutine: compact β
β open L0+L1 readers (no lock) β
β newMergedIter β write new L1 SSTables (no lock) β
β db.mu.Lock() β manifest update β manifest.save() β
β delete old files (best-effort, no lock) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Thread safety model
Lab 08 uses a single sync.Mutex (db.mu) to protect all mutable state:
| Protected state | Why |
|---|---|
db.mem | Written by the write path; read by the flush goroutine |
db.imm | Set by maybeFlushLocked, cleared by flushImm |
db.wal | Rotated by maybeFlushLocked |
db.seqNum | Incremented by every write |
db.manifest.Levels | Updated by flush/compact |
db.bgErr | Set by background goroutines on error |
Reads are not taken under db.mu for the actual disk I/O. The lock is held
only long enough to snapshot the current pointers:
func (db *DB) Get(key []byte) ([]byte, bool) {
db.mu.Lock()
readSeq := db.seqNum - 1 // snapshot the seqNum
mem := db.mem // snapshot the pointer
imm := db.imm // snapshot the pointer (may be nil)
l0 := append(...) // shallow copy the file-name slice
l1 := append(...)
db.mu.Unlock() // β release BEFORE any disk I/O
return db.getAt(key, readSeq, mem, imm, l0, l1)
}
This pattern β βlock, snapshot, unlock, then do I/Oβ β is the key to concurrent reads without blocking writers. Pointer snapshots are safe because:
db.memanddb.immpoint to immutable skip lists (frozen once handed to the flush goroutine).- SSTable files are immutable once written.
Why not sync.RWMutex? Get holds the mutex for less than a microsecond
(just to copy 4 pointers and a slice header), so the benefit of allowing
multiple concurrent readers is negligible. The real concurrency comes from
releasing the lock before I/O.
WAL record format (lab08-specific)
Lab 08βs WAL format is simpler than lab03βs WriteBatch format. Each record
is a single key-value operation:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 8 bytes β 1 byte β 4 bytes β key bytes β value bytes β
β seqNum LE β keyType β keyLen LEβ β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Byte-level example: Put("name","Alice") at seqNum=5
Encoding:
encodeWALRecord(5, typeValue, "name", "Alice")
buf[0:8] = 05 00 00 00 00 00 00 00 β seqNum=5, little-endian uint64
buf[8] = 01 β typeValue=1
buf[9:13] = 04 00 00 00 β keyLen=4 (len("name")), LE uint32
buf[13:17]= 6e 61 6d 65 β "name" in ASCII
buf[17:22]= 41 6c 69 63 65 β "Alice" in ASCII (no length prefix!)
Total: 22 bytes.
Value has no length prefix. The entire suffix after buf[13+keyLen] is the
value. This works because each WAL record is framed by the WAL layer (CRC +
length header from lab01). The WAL frame tells the reader exactly how many
bytes are in each record, so the value length = recordLen - 13 - keyLen.
Comparison with lab03 WriteBatch format
| Aspect | lab03 WriteBatch | lab08 WAL record |
|---|---|---|
| Granularity | Batch of N operations | One operation |
| Header | 8B seqNum + 4B count | 8B seqNum + 1B type + 4B keyLen |
| Value length | Explicit 4B prefix | Implicit (record length - key length - 13) |
| Use case | Atomic multi-key writes | Simple single-key writes |
write() execution trace: Put("city","London")
Call: db.Put("city","London")
β db.write(typeValue, "city", "London")
1. db.mu.Lock()
seq = db.seqNum (e.g. 42)
db.seqNum++ β db.seqNum = 43
2. rec = encodeWALRecord(42, typeValue, "city", "London")
= [2a 00 00 00 00 00 00 00 β seqNum=42 LE
01 β typeValue
04 00 00 00 β keyLen=4
63 69 74 79 β "city"
4c 6f 6e 64 6f 6e] β "London"
3. db.wal.append(rec):
WAL frame: CRC32(rec) + len(rec) as 3-byte header + rec body
Appended to CURRENT.wal; fdatasync() called.
4. db.mem.add(42, typeValue, "city", "London"):
Internal key = encodeInternalKey("city", 42, typeValue)
= [63 69 74 79 01 2a 00 00 00 00 00 00]
Skip list insert with this internal key.
5. db.maybeFlushLocked()
if db.mem.approximateSize() < 4 MiB: return (likely case)
6. db.mu.Unlock()
Return nil (success)
flushImm lock-release pattern annotated
func (db *DB) flushImm(imm *memTable) {
// ββ Outside lock: allocate file number (atomic, not mutex) ββ
db.mu.Lock()
num := db.nextFileNum.Add(1) // atomic increment; no deadlock risk
db.mu.Unlock()
path := fmt.Sprintf("%s/%06d.sst", db.dir, num)
// ββ Outside lock: all SSTable I/O ββ
bld, err := newSSTBuilder(path)
if err != nil { /* error path: set bgErr under lock, imm=nil */ }
it := imm.newIter() // imm is immutable; no lock needed to read it
for it.valid() {
bld.add(it.key(), it.value()) // sequential writes to .sst file
it.next()
}
bld.finish() // fdatasync β this is the expensive step
bld.close()
// ββ Under lock: update manifest, clear imm slot ββ
db.mu.Lock()
defer db.mu.Unlock()
db.manifest.Levels[0] = append(db.manifest.Levels[0], path)
db.manifest.NextSeq = db.seqNum // checkpoint seqNum for crash recovery
db.manifest.save() // write MANIFEST.tmp β rename atomically
db.imm = nil // signal: flush complete, slot is free
// Trigger compaction if L0 has grown too large:
if len(db.manifest.Levels[0]) >= compactL0Trigger {
db.bgWg.Add(1)
go func() { defer db.bgWg.Done(); db.compact() }()
}
}
Why is imm safe to read without a lock? Once db.imm = db.mem and
db.mem = newMemTable() execute under the lock, imm is frozen β no writer
will ever call imm.add() again. The flushImm goroutine is the only reader.
Goβs memory model guarantees that the goroutine sees all writes to imm that
happened before the goroutine was launched (the go statement acts as a
synchronization point).
compact() lock-release pattern annotated
func (db *DB) compact() {
// 1. Snapshot file lists under lock:
db.mu.Lock()
l0 := append([]string(nil), db.manifest.Levels[0]...)
l1 := append([]string(nil), db.manifest.Levels[1]...)
db.mu.Unlock() // β release lock before opening files
// 2. Open all source SSTables (I/O, no lock):
var sources []rawIter
for _, p := range append(l0, l1...) {
r, _ := openSST(p)
sources = append(sources, r.newIter())
}
// 3. K-way merge + write new L1 files (I/O, no lock):
mi := newMergedIter(sources)
var newL1 []string
// ... build new SSTables, dedup, drop tombstones ...
// 4. Commit new L1 to manifest under lock:
db.mu.Lock()
db.manifest.Levels[0] = nil // clear all L0
db.manifest.Levels[1] = newL1 // replace L1
db.manifest.NextSeq = db.seqNum
db.manifest.save()
db.mu.Unlock()
// 5. Delete old files (best-effort, no lock):
for _, p := range append(l0, l1...) { os.Remove(p) }
}
Steps 2 and 3 β which may take 50β200 ms for 16β32 MiB of data β run entirely
without the lock. Writes continue freely to db.mem during this time. The
lock is held for step 1 (~1 Β΅s) and step 4 (~1 Β΅s + manifest.save latency).
Is it safe to open L0 files while a flush might be adding to L0? Yes.
compact() took a snapshot of l0 at step 1. Any new L0 files added after
that snapshot are not in l0, so compact() does not open them. Those new
files will be picked up by the next compaction.
applyWALRecord: crash recovery filter annotated
func (db *DB) applyWALRecord(rec []byte) error {
// Decode the WAL record:
seqNum := binary.LittleEndian.Uint64(rec[0:8])
kt := keyType(rec[8])
keyLen := binary.LittleEndian.Uint32(rec[9:13])
key := rec[13 : 13+keyLen]
value := rec[13+keyLen:]
// KEY INSIGHT: Skip records already captured in SSTables.
// manifest.NextSeq is the seqNum that was live when the last flush
// completed. Any WAL record with seqNum < NextSeq is already
// represented in an SSTable. Replaying it would add a duplicate
// version to the MemTable β harmless but wasteful.
if seqNum < db.manifest.NextSeq {
return nil // already in an SSTable; skip
}
db.mem.add(seqNum, kt, key, value)
if seqNum >= db.seqNum {
db.seqNum = seqNum + 1 // advance seqNum past the replayed record
}
return nil
}
The crash scenario this handles:
1. Write seqNums 1..100 β all in CURRENT.wal
2. Flush triggered: flushImm writes seqNums 1..100 to 000003.sst
3. MANIFEST saved with NextSeq=101
4. CURRENT.wal still exists (not deleted yet)
5. CRASH
On reopen:
manifest.NextSeq = 101
WAL replay: reads records 1..100 from CURRENT.wal
applyWALRecord: seqNum < 101 β skip (all 100 records)
MemTable is empty after replay β correct!
All data is in 000003.sst, found via MANIFEST.
Without the seqNum < manifest.NextSeq guard, recovery would add duplicate
versions of every key to the MemTable, causing a second flush to generate a
duplicate SSTable. The MergedIteratorβs dedup would still return correct
answers, but at higher cost.
seqNum = 1 reservation: proof
if db.seqNum == 0 {
db.seqNum = 1 // reserve 0 so empty-DB snapshots see nothing
}
GetSnapshot() returns snap.seqNum = db.seqNum - 1. If db.seqNum = 0:
snap.seqNum = db.seqNum - 1 = 0 - 1 = uint64 max = 18446744073709551615
A snapshot with readSeq = MaxUint64 would see every record ever written
including future ones β incorrect. By starting at 1:
db.seqNum = 1 β snap.seqNum = 0
All stored records have seqNum β₯ 1.
MergedIterator filter: seqNum β€ 0 β false β no records visible.
Empty-DB snapshot correctly sees nothing. β
bgErr propagation
Background goroutines (flushImm, compact) can encounter I/O errors β disk
full, permissions failure, corrupt state. The error is stored in db.bgErr
under db.mu and then checked by the next write operation:
func (db *DB) write(kt keyType, key, value []byte) error {
db.mu.Lock()
defer db.mu.Unlock()
if db.bgErr != nil {
return db.bgErr // surface background error to the next writer
}
// ... rest of write path ...
}
This is analogous to Goβs error-channel pattern: the background goroutine communicates failure to the foreground, which returns the error to the caller. The database is then in a degraded state and should be closed.
In production engines (RocksDB), background errors are additionally exposed
via a StatusCode that the user can query at any time, and some errors trigger
automatic recovery (retry the flush, skip the corrupt file, etc.).
Running the 27 tests
cd leveldb
go test ./lab08/... -v -count=1
Expected output (abbreviated):
--- PASS: TestOpenClose (0.00s)
--- PASS: TestPutGet (0.01s)
--- PASS: TestDelete (0.00s)
--- PASS: TestPersistenceAcrossReopen (0.05s)
--- PASS: TestIteratorOrder (0.00s)
--- PASS: TestSnapshotIsolation (0.00s)
--- PASS: TestSnapshotIterator (0.00s)
--- PASS: TestFlushAndReopen (0.05s)
--- PASS: TestCompaction (0.18s)
--- PASS: TestCompactionDeduplicates (0.21s)
--- PASS: TestTombstoneNotVisible (0.08s)
--- PASS: TestConcurrentWrites (0.09s)
--- PASS: TestConcurrentReads (0.04s)
--- PASS: TestWriteAfterClose (0.00s)
--- PASS: TestManifestRecovery (0.06s)
--- PASS: TestWALCrashRecovery (0.04s)
--- PASS: TestSnapshotRelease (0.00s)
--- PASS: TestReadYourWrites (0.00s)
--- PASS: TestLargeValues (0.02s)
--- PASS: TestManyFlushes (0.52s)
--- PASS: TestScanRange (0.01s)
--- PASS: TestSnapshotScanIsolation (0.01s)
--- PASS: TestOverwriteKey (0.01s)
--- PASS: TestDeleteNonExistent (0.00s)
--- PASS: TestBgErrSurfaces (0.00s)
--- PASS: TestCompactProducesNonOverlappingL1 (0.19s)
--- PASS: TestSeqNumRestoredAfterReopen (0.03s)
ok github.com/10xdev/leveldb/lab08
Test category summary
| Category | Tests | Whatβs verified |
|---|---|---|
| Basic API | TestPutGet, TestDelete, TestOverwriteKey, TestDeleteNonExistent | Core read/write correctness |
| Durability | TestPersistenceAcrossReopen, TestFlushAndReopen, TestWALCrashRecovery | Data survives process restart |
| Compaction | TestCompaction, TestCompactionDeduplicates, TestTombstoneNotVisible, TestCompactProducesNonOverlappingL1 | Merge correctness |
| Concurrency | TestConcurrentWrites, TestConcurrentReads | Race-condition detection |
| Snapshots | TestSnapshotIsolation, TestSnapshotIterator, TestSnapshotRelease, TestSnapshotScanIsolation | MVCC correctness |
| Recovery | TestManifestRecovery, TestWALCrashRecovery, TestSeqNumRestoredAfterReopen | Crash safety |
| Error handling | TestBgErrSurfaces, TestWriteAfterClose | Error propagation |
| Performance | TestManyFlushes, TestLargeValues | Sustained load |
Run with -race to also validate the thread safety model:
go test ./lab08/... -race -count=1
Running the demo
go run ./lab08/demo
The demo runs six scenarios:
- Basic Put/Get/Delete β confirms the core API works.
- Sorted iteration β writes 10 out-of-order keys, iterates and verifies they come back in sorted order.
- Snapshot isolation β takes a snapshot, writes new values, verifies the snapshot still sees old values.
- Persistence across reopen β writes keys, closes DB, reopens, reads back.
- Write throughput β writes 5,000 keys Γ 256 bytes and reports keys/s.
- Snapshot iterator β range scan using a snapshot.
Tuning constants
| Constant | Default | Effect of increasing | Trade-off |
|---|---|---|---|
flushThreshold | 4 MiB | Larger MemTable β fewer L0 files, less compaction | More RAM per open DB |
compactL0Trigger | 4 files | More L0 files β worse read amplification during burst | Fewer compaction pauses |
l1SplitSize | 2 MiB | Larger L1 files β fewer SST files, less file-open overhead | More overlap when compacting |
skip list maxLevel | 12 | More levels β faster search in large MemTables | More memory per node |
skip list prob | 0.25 | Lower β fewer levels β less memory, more search time | Trade memory vs speed |
Deriving the optimal flushThreshold
Assume:
- Target L0 compaction frequency: once per minute.
- Write rate: 50 MB/s (NVMe with group commit).
- Time between flushes: 4 MiB / 50 MB/s = 80 ms β too frequent.
For 1-minute intervals: threshold = 50 MB/s Γ 60s = 3 GB β use 3 GiB.
But this requires 3 GiB of RAM just for the MemTable. Real systems use a
write buffer size of 64 MiBβ1 GiB, trading compaction frequency for RAM.
FoundationDB parallel
Lab 08 is the complete equivalent of what runs inside a single FDB storage server β minus network replication. FDB adds:
-
Distributed WAL: the transaction log is striped across 3+ log servers. Every commit is acknowledged only after a majority of log servers have durably written the entry. Our
wal.append+fdatasyncis the single-node version of this quorum write. -
Sharding: key ranges are partitioned across many storage servers. Each storage server runs an instance of exactly the engine we built here. The data distributor rebalances shards when a server is overloaded or a server fails.
-
Flow and simulation testing: FDB is written in a cooperative threading model called Flow, which enables the simulator to inject arbitrary network delays, disk failures, and process crashes at any point in execution β and verify that the database is always consistent. Our
go test -raceis a pale shadow of this: it detects data races but not logical consistency under failure. -
Conflict detection: FDBβs proxy checks at commit time whether the transactionβs read set was modified by a concurrent committed transaction. Our MVCC allows concurrent writes to the same key; FDB rejects them and requires the client to retry with the new value.
-
Redwood storage engine: FDBβs modern storage engine is a B+-tree with versioned pages, replacing the older SQLite-based storage. The internal key encoding (
key || version) is identical in concept to ouruserKey || (seqNum<<8 | keyType).
Data Structures in LevelDB β Go & Python
- The insert path in one picture
- 1. Internal Key
- 2. Skip List
- 3. MemTable
- 4. Write-Ahead Log (WAL)
- 5. SSTable (Sorted String Table)
- 6. Min-Heap (K-way Merge)
- 7. Red-Black Tree
- 8. B-Tree
- 9. B+ Tree
- Summary: all data structures compared
- Summary: data structures touched by
db.Put("name","Alice")
This guide maps every data structure used across labs 01β08 to the exact
Go source in this repo, then shows a Python equivalent you can run in a
REPL. Every example is anchored to the same scenario: inserting the three
key-value pairs ("age","25"), ("city","London"), and ("name","Alice")
β the same data used in the lab demos.
The insert path in one picture
db.Put("name","Alice")
β
βββΊ 1. Encode Internal Key βββββββββββββββββββββββββββββββββββββββββββββββββ
β "name" || uint64(seqNum<<8 | TypeValue) β 8 extra bytes β
β β
βββΊ 2. WAL.Append(record) βββββββΊ disk: [4B len][4B crc][payload] β
β fdatasync guarantees durability before anything in-memory changes β
β β
βββΊ 3. SkipList.Put(internalKey, value) βββββββββββββββββββββββββββββββββββββ
β probabilistic sorted linked list; O(log n) insert
β
β (when MemTable β₯ 4 MiB) βββββββββββββββββββββββββββββββββββββββββββββββββ
βββΊ 4. SSTable Builder.Add(internalKey, value) β iterator over SkipList
β varint-encoded records + in-memory index; fdatasync on Finish
β
β (when L0 β₯ 4 files) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββΊ 5. Min-Heap merge (K-way merge)
priority queue over all L0+L1 iterators; dedup by MVCC seqNum
1. Internal Key
Concept
Every key stored in the MemTable and in SSTables is an internal key:
internal key = userKey || tag (8 bytes, little-endian)
where tag = (seqNum << 8) | keyType
seqNum: monotonically increasing write counter (uint64)
keyType: 0 = deletion tombstone, 1 = value
Sort order: user key ascending, then sequence number descending.
This means ("name", seqNum=5) sorts before ("name", seqNum=3), so a
forward scan always encounters the most recent version first.
Go implementation (lab02/key.go)
// KeyType distinguishes a value record from a deletion marker.
type KeyType uint8
const (
TypeDelete KeyType = 0
TypeValue KeyType = 1
)
// EncodeInternalKey builds: userKey || uint64(seqNum<<8 | kt) little-endian.
func EncodeInternalKey(userKey []byte, seqNum uint64, kt KeyType) []byte {
buf := make([]byte, len(userKey)+8)
copy(buf, userKey)
tag := (seqNum << 8) | uint64(kt)
binary.LittleEndian.PutUint64(buf[len(userKey):], tag)
return buf
}
// DecodeInternalKey splits back into components.
func DecodeInternalKey(b []byte) (userKey []byte, seqNum uint64, kt KeyType) {
tag := binary.LittleEndian.Uint64(b[len(b)-8:])
return b[:len(b)-8], tag >> 8, KeyType(tag & 0xff)
}
// CompareInternal: userKey ASC, seqNum DESC.
func CompareInternal(a, b []byte) int {
ukA, seqA, _ := DecodeInternalKey(a)
ukB, seqB, _ := DecodeInternalKey(b)
if c := bytes.Compare(ukA, ukB); c != 0 {
return c
}
if seqA > seqB { return -1 }
if seqA < seqB { return 1 }
return 0
}
Python implementation
import struct
TYPE_DELETE = 0
TYPE_VALUE = 1
def encode_internal_key(user_key: bytes, seq_num: int, key_type: int) -> bytes:
"""
Encodes user_key + (seq_num << 8 | key_type) as 8 bytes little-endian.
>>> k = encode_internal_key(b"name", 3, TYPE_VALUE)
>>> k[:4]
b'name'
>>> int.from_bytes(k[4:], 'little') == (3 << 8 | TYPE_VALUE)
True
"""
tag = (seq_num << 8) | key_type
return user_key + struct.pack('<Q', tag) # '<Q' = little-endian uint64
def decode_internal_key(b: bytes) -> tuple[bytes, int, int]:
"""Returns (user_key, seq_num, key_type)."""
tag = struct.unpack('<Q', b[-8:])[0]
return b[:-8], tag >> 8, tag & 0xFF
def compare_internal(a: bytes, b: bytes) -> int:
"""Returns -1, 0, or 1. user_key ASC, seq_num DESC."""
uk_a, seq_a, _ = decode_internal_key(a)
uk_b, seq_b, _ = decode_internal_key(b)
if uk_a < uk_b: return -1
if uk_a > uk_b: return 1
# same user key β higher seqNum sorts first (descending)
if seq_a > seq_b: return -1
if seq_a < seq_b: return 1
return 0
Example: Put("name","Alice") at seqNum=3
key = encode_internal_key(b"name", 3, TYPE_VALUE)
print(key.hex())
# 6e616d65 01 03 00 00 00 00 00 00
# "name" β βββ seqNum=3 stored in tag (little-endian)
# type=1
user_key, seq, kt = decode_internal_key(key)
print(user_key, seq, kt) # b'name' 3 1
The full 12-byte internal key in hex:
6e 61 6d 65 01 03 00 00 00 00 00 00
n a m e βββββββ tag LE: (3<<8|1) = 0x0301 βββββββ
In real database engines
LevelDB (db/dbformat.h, db/dbformat.cc)
The exact same layout: user_key + PackSequenceAndType(seq, type) where
PackSequenceAndType = (seq << 8) | type. The comparator
InternalKeyComparator::Compare mirrors CompareInternal above. Every
MemTable entry, every SSTable record, and every iterator key is an internal
key β the user never sees the 8-byte suffix.
RocksDB (include/rocksdb/types.h, db/dbformat.h)
Identical tag layout. RocksDB adds two extra key types:
kTypeMerge = 2β merge operator result (partial updates, e.g. increment a counter without read-modify-write)kTypeBlobIndex = 18β key whose value lives in a separate blob file (BlobDB)
The sequence number is 56 bits (not 64) β the top 8 bits encode the type,
giving room for type codes up to 255 while keeping the tag in one uint64.
Pebble (CockroachDBβs storage engine, internal/base/internal_key.go)
Same 8-byte tag, same comparator direction. Pebble adds InternalKeyKindRangeDelete
and InternalKeyKindRangeKeySet for efficient range tombstones β instead of
one tombstone per key, a single record covers [start, end) and is encoded
as a special boundary key in SSTable metadata blocks.
Badger (value.go)
Badger separates large values into a value log (vlog) file β the MemTable
stores (internalKey β valuePointer{fileID, offset, len}) instead of the
value directly, keeping the skip list compact. The tag byte is extended to
distinguish BitValuePointer from inline values.
Key design lesson: the 8-byte tag appended to every key is the minimal representation of a logical clock. Any storage engine that needs MVCC, snapshot reads, or crash-safe deletes without in-place updates will arrive at the same or an equivalent design.
2. Skip List
Concept
A skip list is a layered singly-linked list. Level 0 contains all nodes in sorted order. Each higher level is a probabilistic 25%-sampled subset of the level below. A search starts at the highest level and drops down, giving O(log n) expected time for both lookups and inserts β the same as a balanced BST β but without any rebalancing.
Level 3: head ββββββββββββββββββββββββββββ "name,5" βββββββ nil
Level 2: head ββββββββ "city,7" βββββββββββ "name,5" βββββββ nil
Level 1: head β "age,2" β "city,7" ββββββββ "name,5" βββββββ nil
Level 0: head β "age,2" β "city,7" β "city,6" β "name,5" ββ nil
β older version of "city"
Go implementation (lab02/skiplist.go)
const (
maxLevel = 12 // max tower height
prob = 0.25 // 25% chance to promote to next level
)
type node struct {
key []byte
value []byte
next [maxLevel]*node
}
type SkipList struct {
head *node
length int
level int
}
func randomLevel() int {
lvl := 1
for lvl < maxLevel && rand.Float64() < prob {
lvl++
}
return lvl
}
func (sl *SkipList) Put(key, value []byte) {
// update[i] = rightmost node at level i that is < key
var update [maxLevel]*node
cur := sl.head
for i := sl.level - 1; i >= 0; i-- {
for cur.next[i] != nil && CompareInternal(cur.next[i].key, key) < 0 {
cur = cur.next[i]
}
update[i] = cur
}
// Exact match? Update in-place.
if n := update[0].next[0]; n != nil && CompareInternal(n.key, key) == 0 {
n.value = value
return
}
lvl := randomLevel()
if lvl > sl.level {
for i := sl.level; i < lvl; i++ { update[i] = sl.head }
sl.level = lvl
}
n := &node{key: key, value: value}
for i := 0; i < lvl; i++ {
n.next[i] = update[i].next[i]
update[i].next[i] = n
}
sl.length++
}
Python implementation
import random
from typing import Optional
MAX_LEVEL = 12
PROB = 0.25
class _Node:
__slots__ = ('key', 'value', 'next')
def __init__(self, key: bytes, value: bytes, level: int):
self.key = key
self.value = value
self.next: list[Optional['_Node']] = [None] * level
def _random_level() -> int:
lvl = 1
while lvl < MAX_LEVEL and random.random() < PROB:
lvl += 1
return lvl
class SkipList:
"""Probabilistic sorted map keyed by internal keys."""
def __init__(self):
self._head = _Node(b'', b'', MAX_LEVEL)
self._level = 1
self._len = 0
def put(self, key: bytes, value: bytes):
update = [None] * MAX_LEVEL
cur = self._head
for i in range(self._level - 1, -1, -1):
while (cur.next[i] is not None and
compare_internal(cur.next[i].key, key) < 0):
cur = cur.next[i]
update[i] = cur
nxt = update[0].next[0]
if nxt is not None and compare_internal(nxt.key, key) == 0:
nxt.value = value # exact match: update in-place
return
lvl = _random_level()
if lvl > self._level:
for i in range(self._level, lvl):
update[i] = self._head
self._level = lvl
n = _Node(key, value, lvl)
for i in range(lvl):
n.next[i] = update[i].next[i]
update[i].next[i] = n
self._len += 1
def get(self, key: bytes) -> Optional[bytes]:
cur = self._head
for i in range(self._level - 1, -1, -1):
while (cur.next[i] is not None and
compare_internal(cur.next[i].key, key) < 0):
cur = cur.next[i]
nxt = cur.next[0]
if nxt is not None and compare_internal(nxt.key, key) == 0:
return nxt.value
return None
def __iter__(self):
"""Yields (key, value) in CompareInternal order."""
cur = self._head.next[0]
while cur is not None:
yield cur.key, cur.value
cur = cur.next[0]
Example: insert out-of-order, read back sorted
sl = SkipList()
sl.put(encode_internal_key(b"name", 3, TYPE_VALUE), b"Alice")
sl.put(encode_internal_key(b"age", 1, TYPE_VALUE), b"25")
sl.put(encode_internal_key(b"city", 2, TYPE_VALUE), b"London")
for ik, val in sl:
uk, seq, kt = decode_internal_key(ik)
print(f" {uk.decode():8s} seq={seq} -> {val.decode()}")
Output β always sorted regardless of insert order:
age seq=1 -> 25
city seq=2 -> London
name seq=3 -> Alice
MVCC: overwrite βcityβ at a higher seqNum
sl.put(encode_internal_key(b"city", 7, TYPE_VALUE), b"Paris")
for ik, val in sl:
uk, seq, kt = decode_internal_key(ik)
print(f" {uk.decode():8s} seq={seq} -> {val.decode()}")
Output β both versions exist; newer (seq=7) sorts first:
age seq=1 -> 25
city seq=7 -> Paris <- newer version first (seqNum DESC)
city seq=2 -> London <- older version second
name seq=3 -> Alice
A read at readSeq=5 sees βLondonβ; a read at readSeq=8 sees βParisβ.
In real database engines
LevelDB (db/skiplist.h)
The skip list is a single-threaded writer, multi-threaded reader design.
Inserts are protected by a mutex in DBImpl; readers traverse without any
locks because nodes are only ever added (never deleted from) the list, and
pointer writes are sequentially consistent. The maximum level is 12;
promotion probability is 1/4, giving ~4.3 nodes on average per key.
RocksDB (memtable/skiplist.h, memtable/inlineskiplist.h)
RocksDB provides three MemTable implementations configurable at runtime:
SkipListβ same design as LevelDBInlineSkipListβ key stored inline in the node (no extra heap allocation), CAS-based concurrent inserts without a global write lockHashSkipListβ hash table of per-bucket skip lists; O(1) point lookup, O(n) full scanHashLinkedListβ hash table of sorted singly-linked lists; lower memory than skip lists for small buckets
The concurrent InlineSkipList uses a std::atomic<Node*> for each next
pointer and compare_exchange_weak to splice a new node in β this is the
lock-free insert you cannot easily do with a red-black tree.
Apache Cassandra (org.apache.cassandra.db.Memtable)
Cassandraβs MemTable is a ConcurrentSkipListMap<DecoratedKey, ColumnFamily>
(Java stdlib). Flush is triggered by heap pressure (JVM GC), not just
byte count. Multiple MemTables may be flushing concurrently while a fresh
one accepts new writes β the same immutable MemTable pattern as LevelDB.
Redis sorted sets (t_zset.c)
Redis uses a skip list with 32 levels (not 12) and promotion probability
1/4. The skip list is paired with a hash table in the zset struct:
typedef struct zset {
dict *dict; // hash: member β score (O(1) lookup)
zskiplist *zsl; // skip list sorted by score (O(log n) rank/range)
} zset;
ZRANGEBYSCORE, ZRANK, and ZRANGE all use the skip list. ZSCORE uses
the hash table. This dual-index design is only viable because both structures
are in-memory β on-disk you would use a B+ tree for both.
3. MemTable
Concept
MemTable wraps the skip list and manages the MVCC sequence number. It is the
in-memory write buffer: every db.Put and db.Delete lands here first. When
its size exceeds flushThreshold (4 MiB), it is frozen as immutable and
flushed to an SSTable file on disk.
Go implementation (lab02/memtable.go)
type MemTable struct {
sl *SkipList
size int // approximate bytes
}
// Add inserts one mutation β encodes the internal key then delegates.
func (m *MemTable) Add(seqNum uint64, kt KeyType, key, value []byte) {
ikey := EncodeInternalKey(key, seqNum, kt)
m.sl.Put(ikey, value)
m.size += len(ikey) + len(value)
}
// Get returns the latest version of key visible at readSeq.
func (m *MemTable) Get(key []byte, readSeq uint64) ([]byte, bool) {
seekKey := EncodeInternalKey(key, readSeq, TypeValue)
it := m.sl.NewIter()
it.Seek(seekKey)
if !it.Valid() { return nil, false }
uk, seq, kt := DecodeInternalKey(it.Key())
if !bytes.Equal(uk, key) || seq > readSeq { return nil, false }
if kt == TypeDelete { return nil, false }
return it.Value(), true
}
Python implementation
class MemTable:
def __init__(self):
self._sl = SkipList()
self._size = 0
def add(self, seq_num: int, key_type: int, key: bytes, value: bytes):
ikey = encode_internal_key(key, seq_num, key_type)
self._sl.put(ikey, value)
self._size += len(ikey) + len(value)
@property
def approximate_size(self) -> int:
return self._size
def get(self, key: bytes, read_seq: int) -> Optional[bytes]:
seek_key = encode_internal_key(key, read_seq, TYPE_VALUE)
for ik, val in self._sl:
if compare_internal(ik, seek_key) < 0:
continue
uk, seq, kt = decode_internal_key(ik)
if uk != key:
return None
if seq > read_seq:
continue
if kt == TYPE_DELETE:
return None
return val
return None
Example: full Put β Get trace, overwrite, delete
mem = MemTable()
mem.add(1, TYPE_VALUE, b"age", b"25")
mem.add(2, TYPE_VALUE, b"city", b"London")
mem.add(3, TYPE_VALUE, b"name", b"Alice")
print(mem.get(b"name", 3)) # b'Alice'
print(mem.get(b"city", 3)) # b'London'
# Overwrite "city" at seqNum=7
mem.add(7, TYPE_VALUE, b"city", b"Paris")
print(mem.get(b"city", 7)) # b'Paris' <- new version
print(mem.get(b"city", 2)) # b'London' <- old snapshot still sees London
# Delete "age" at seqNum=10
mem.add(10, TYPE_DELETE, b"age", b"")
print(mem.get(b"age", 10)) # None <- tombstone hides the key
print(mem.get(b"age", 1)) # b'25' <- seqNum=1 predates the delete
In real database engines
LevelDB (db/memtable.h, db/memtable.cc)
One active MemTable + at most one immutable MemTable being flushed.
The flush pipeline:
DBImpl::MakeRoomForWrite()β if active MemTable β₯write_buffer_size(4 MiB default), rotate it to immutable and open a fresh one.- Background thread calls
CompactMemTable()βWriteLevel0Table()βBuildTable()βTableBuilder::Finish(). - Once the SSTable is fsyncβd, the WAL segment covering those writes is deleted.
The MemTable holds a reference count; the WAL deletion is safe only when the reference drops to zero (no active iterator over the immutable table).
RocksDB (db/memtable.h, memtable/)
RocksDB allows multiple concurrent active MemTables (max_write_buffer_number).
This hides flush latency: while one MemTable is being flushed (potentially
taking hundreds of milliseconds on a slow disk), the next MemTable absorbs
new writes. Atomic flush mode can flush all column familiesβ MemTables
atomically to avoid cross-CF consistency issues.
Apache HBase (HStore, MemStore)
HBase is built on HDFS; every flush creates a new StoreFile (HFile, an
SSTable variant). HBase supports two MemStore implementations:
DefaultMemStoreβConcurrentSkipListMap(like Cassandra)CompactingMemStoreβ in-memory compaction before flush, reducing the number of SSTables on L0
ScyllaDB (C++ reimplementation of Cassandra) ScyllaDB uses a per-CPU shard model: each shard owns its own MemTable and never shares memory with other shards (no lock contention). Flush is triggered by a configurable dirty memory fraction of the shardβs memory pool, not a global byte threshold.
Key design lesson: the MemTable is the only mutable component in an LSM tree. Every design decision in LevelDB β the WAL (durability), the immutable MemTable (zero-copy flush), the sequence number (snapshot reads) β exists to let this small, fast in-memory structure absorb writes safely.
4. Write-Ahead Log (WAL)
Concept
Before any write touches the MemTable, it is durably appended to the WAL file. On crash, the WAL is replayed to reconstruct the MemTable. Each record is wrapped in an 8-byte header that contains the payload length and a CRC-32 checksum. A truncated final record (the only one that can be partially written on crash) is silently dropped.
βββββββββββββββββββββββββββββββββββββββββββββββ
β 4 bytes: payload length (little-endian) β
β 4 bytes: CRC-32 of payload (IEEE) β
β N bytes: payload β
βββββββββββββββββββββββββββββββββββββββββββββββ
Go implementation (lab01/wal.go)
const headerSize = 8 // 4B len + 4B crc32
type WAL struct{ f *os.File }
func (w *WAL) Append(data []byte) error {
var hdr [headerSize]byte
binary.LittleEndian.PutUint32(hdr[0:4], uint32(len(data)))
binary.LittleEndian.PutUint32(hdr[4:8], crc32.ChecksumIEEE(data))
if _, err := w.f.Write(hdr[:]); err != nil { return err }
if _, err := w.f.Write(data); err != nil { return err }
return w.f.Sync() // fdatasync: flush OS buffer to durable storage
}
func Recover(path string) ([][]byte, error) {
// Reads [hdr | data] records; verifies CRC; stops on truncated/corrupt.
// Returns (nil, nil) if the file does not exist.
}
Python implementation
import os, struct, zlib
HEADER_SIZE = 8 # 4B length + 4B CRC32
class WAL:
def __init__(self, path: str, mode: str = 'ab'):
self._f = open(path, mode)
def append(self, data: bytes):
crc = zlib.crc32(data) & 0xFFFFFFFF
header = struct.pack('<II', len(data), crc)
self._f.write(header + data)
self._f.flush()
os.fsync(self._f.fileno()) # durability guarantee
def close(self):
self._f.close()
@staticmethod
def recover(path: str) -> list[bytes]:
records = []
if not os.path.exists(path):
return records
with open(path, 'rb') as f:
while True:
hdr = f.read(HEADER_SIZE)
if len(hdr) < HEADER_SIZE:
break
length, stored_crc = struct.unpack('<II', hdr)
data = f.read(length)
if len(data) < length:
break
if (zlib.crc32(data) & 0xFFFFFFFF) != stored_crc:
break
records.append(data)
return records
Example: write three records, inspect bytes, recover
import tempfile
path = tempfile.mktemp(suffix='.wal')
wal = WAL(path, mode='wb')
payloads = [b"age\x0025", b"city\x00London", b"name\x00Alice"]
for p in payloads:
wal.append(p)
wal.close()
# Inspect raw bytes on disk:
with open(path, 'rb') as f:
raw = f.read()
offset = 0
while offset < len(raw):
length, crc = struct.unpack('<II', raw[offset:offset+8])
data = raw[offset+8 : offset+8+length]
print(f" len={length:3d} crc={crc:#010x} payload={data}")
offset += 8 + length
len= 7 crc=0x... payload=b'age\x0025'
len= 11 crc=0x... payload=b'city\x00London'
len= 10 crc=0x... payload=b'name\x00Alice'
# Crash recovery:
recovered = WAL.recover(path)
for rec in recovered:
key, _, val = rec.partition(b'\x00')
print(f" {key.decode()} = {val.decode()}")
# age = 25
# city = London
# name = Alice
In real database engines
PostgreSQL (src/backend/access/transam/xlog.c, pg_wal/)
Postgres calls its WAL the Write-Ahead Log (same name). Key differences
from LevelDBβs WAL:
- 8 KiB pages inside 16 MiB segment files (
000000010000000000000001, β¦) - Each page has a
XLogPageHeaderData(magic + TLI + LSN) - Records are typed:
XLOG_HEAP_INSERT,XLOG_HEAP_UPDATE,XLOG_BTREE_SPLIT_L, etc. - Group commit: multiple transactionsβ WAL records are written in one
pg_pwrite()call, thenfsync()once for the whole group - Crash recovery in
StartupXLOG()replays from the last checkpoint LSN - Replication streams the same WAL bytes to standbys (physical replication)
or decoded change records (logical replication via
pg_logical)
InnoDB (storage/innobase/log/, ib_logfile0)
InnoDBβs redo log is a circular ring buffer on disk:
- Fixed total size (default 48 MiB, configurable up to 512 GiB in MySQL 8.0)
- Write position =
Log.lsn; checkpoint position =Log.last_checkpoint_lsn - Records called mlog entries:
MLOG_1BYTE,MLOG_REC_INSERT,MLOG_PAGE_CREATE, β¦ - A mini-transaction (
mtr_t) buffers redo records in memory, then commits them atomically to the log buffer with a spin lock fsync()is called on commit (or deferred withinnodb_flush_log_at_trx_commit=2for performance)
SQLite WAL mode (src/wal.c)
SQLiteβs WAL is unusual: it is a shadow copy log, not a redo log.
- Readers read from the WAL first (newest version wins), then the main database file
- The WAL index (
-shmfile, shared memory) maps page numbers to WAL frame positions - Checkpointing copies WAL frames back to the main database file, then resets the WAL
- This design allows concurrent readers and one writer with no read-lock contention
Apache Kafka (commit log as primary data structure) Kafkaβs partition is only an append-only log β there is no separate WAL because the log IS the data. Each segment is a pair of files:
.logβ binary records (offset + size + CRC + payload), same framing as our WAL.indexβ sparse index mapping logical offset β file offset (same idea as SSTable index).timeindexβ sparse timestamp β offset index
Producers write to the active segment; consumers read at arbitrary offsets. Retention by time or size deletes old segments β no compaction needed for the log itself (unless log compaction is enabled, which is then K-way merge).
Key design lesson: the WALβs framing (length + checksum + payload) is universal. Every durable system β from a toy key-value store to PostgreSQL to Kafka β converges on this format because it is the minimum structure needed to detect a torn write and replay clean records after a crash.
5. SSTable (Sorted String Table)
Concept
When the MemTable is flushed to disk it becomes an immutable SSTable file. Keys are written in sorted order. A compact in-memory index (one entry per record, stored at the end of the file) enables O(log n) point lookup.
File layout:
βββββββββββββββββββββββββββββββ
β data record 0 β varint(keyLen) | key | varint(valLen) | val
β data record 1 β
β ... β
βββββββββββββββββββββββββββββββ€ <- indexOffset
β index record 0 β varint(keyLen) | key | 8B LE data offset
β index record 1 β
β ... β
βββββββββββββββββββββββββββββββ€
β 8B indexOffset (LE) β footer
β 8B magic = 0x1edb4b4f β
βββββββββββββββββββββββββββββββ
Varint encoding saves space for small values. Each byte stores 7 bits of payload; the MSB signals βmore bytes followβ:
value 1 β 0x01 (1 byte)
value 128 β 0x80 0x01 (2 bytes)
value 300 β 0xAC 0x02 (2 bytes): 300 = 0b100101100
low 7 bits = 0x2C | 0x80 = 0xAC
next 7 bits = 0x02
Go implementation (lab04/sstable.go)
func appendVarint(buf []byte, v uint64) []byte {
for v >= 0x80 {
buf = append(buf, byte(v)|0x80) // low 7 bits + continuation bit
v >>= 7
}
return append(buf, byte(v))
}
type Builder struct {
f *os.File
index []indexEntry // (key, offset) per record
dataOffset int64
}
func (b *Builder) Add(key, value []byte) error {
offset := b.dataOffset
var buf []byte
buf = appendVarint(buf, uint64(len(key)))
buf = append(buf, key...)
buf = appendVarint(buf, uint64(len(value)))
buf = append(buf, value...)
n, err := b.f.Write(buf)
b.dataOffset += int64(n)
b.index = append(b.index, indexEntry{lastKey: key, offset: uint64(offset)})
return err
}
func (b *Builder) Finish() error {
indexOffset := b.dataOffset
for _, e := range b.index {
var buf []byte
buf = appendVarint(buf, uint64(len(e.lastKey)))
buf = append(buf, e.lastKey...)
var off [8]byte
binary.LittleEndian.PutUint64(off[:], e.offset)
buf = append(buf, off[:]...)
b.f.Write(buf)
}
var footer [16]byte
binary.LittleEndian.PutUint64(footer[0:8], uint64(indexOffset))
binary.LittleEndian.PutUint64(footer[8:16], magic) // 0x1edb4b4f
b.f.Write(footer[:])
return b.f.Sync()
}
Python implementation
import struct, os
MAGIC = 0x1EDB4B4F
def encode_varint(v: int) -> bytes:
out = bytearray()
while v >= 0x80:
out.append((v & 0x7F) | 0x80)
v >>= 7
out.append(v)
return bytes(out)
def decode_varint(data: bytes, offset: int) -> tuple[int, int]:
"""Returns (value, new_offset)."""
v, shift = 0, 0
while True:
b = data[offset]; offset += 1
v |= (b & 0x7F) << shift
if not (b & 0x80):
return v, offset
shift += 7
class SSTBuilder:
def __init__(self, path: str):
self._f = open(path, 'wb')
self._index = [] # [(key_bytes, file_offset)]
self._offset = 0
def add(self, key: bytes, value: bytes):
offset = self._offset
rec = encode_varint(len(key)) + key
rec += encode_varint(len(value)) + value
self._f.write(rec)
self._offset += len(rec)
self._index.append((key, offset))
def finish(self):
index_offset = self._offset
for key, off in self._index:
rec = encode_varint(len(key)) + key + struct.pack('<Q', off)
self._f.write(rec)
self._f.write(struct.pack('<QQ', index_offset, MAGIC))
self._f.flush(); os.fsync(self._f.fileno()); self._f.close()
class SSTReader:
def __init__(self, path: str):
with open(path, 'rb') as f:
self._data = f.read()
index_offset, magic_val = struct.unpack('<QQ', self._data[-16:])
assert magic_val == MAGIC
self._index = []
pos = index_offset
while pos < len(self._data) - 16:
klen, pos = decode_varint(self._data, pos)
key = self._data[pos:pos+klen]; pos += klen
off = struct.unpack('<Q', self._data[pos:pos+8])[0]; pos += 8
self._index.append((key, off))
def get(self, key: bytes, read_seq: int) -> Optional[bytes]:
seek = encode_internal_key(key, read_seq, TYPE_VALUE)
lo, hi = 0, len(self._index)
while lo < hi:
mid = (lo + hi) // 2
if compare_internal(self._index[mid][0], seek) < 0:
lo = mid + 1
else:
hi = mid
for i in range(lo, len(self._index)):
ik, val = self._read_record(self._index[i][1])
uk, seq, kt = decode_internal_key(ik)
if uk != key: break
if seq > read_seq: continue
if kt == TYPE_DELETE: return None
return val
return None
def _read_record(self, offset: int) -> tuple[bytes, bytes]:
klen, p = decode_varint(self._data, offset)
key = self._data[p:p+klen]; p += klen
vlen, p = decode_varint(self._data, p)
return key, self._data[p:p+vlen]
def __iter__(self):
index_offset = struct.unpack('<Q', self._data[-16:-8])[0]
pos = 0
while pos < index_offset:
klen, p = decode_varint(self._data, pos)
key = self._data[p:p+klen]; p += klen
vlen, p = decode_varint(self._data, p)
val = self._data[p:p+vlen]; p += vlen
yield key, val
pos = p
Example: flush MemTable β SSTable β point lookup
import tempfile
entries = [
(encode_internal_key(b"age", 1, TYPE_VALUE), b"25"),
(encode_internal_key(b"city", 2, TYPE_VALUE), b"London"),
(encode_internal_key(b"name", 3, TYPE_VALUE), b"Alice"),
]
sst_path = tempfile.mktemp(suffix='.sst')
bld = SSTBuilder(sst_path)
for ik, val in entries:
bld.add(ik, val)
bld.finish()
print(f"SSTable size: {os.path.getsize(sst_path)} bytes")
rdr = SSTReader(sst_path)
print(rdr.get(b"name", 3)) # b'Alice'
print(rdr.get(b"city", 2)) # b'London'
print(rdr.get(b"age", 0)) # None β readSeq=0 predates all writes
for ik, val in rdr:
uk, seq, kt = decode_internal_key(ik)
print(f" {uk.decode():8s} seq={seq} -> {val.decode()}")
Output:
SSTable size: 87 bytes
b'Alice'
b'London'
None
age seq=1 -> 25
city seq=2 -> London
name seq=3 -> Alice
In real database engines
LevelDB (table/table.cc, table/block.cc)
An LevelDB SSTable has four block types:
- Data blocks β 4 KiB blocks of prefix-compressed sorted records
- Filter block β optional Bloom filter (one filter per 2 KiB of data)
- Metaindex block β maps filter block name β its offset
- Index block β one entry per data block:
(last_key_in_block β BlockHandle{offset, size})
The footer is 48 bytes: metaindex_handle + index_handle + padding + magic (0xdb4775248b80fb57).
This two-level index (index block β data block) means a point lookup costs
two block reads: one to find the right data block, one to read it.
RocksDB (table/block_based/, table/block_based_table_builder.cc)
RocksDBβs BlockBasedTable extends LevelDBβs format:
- Partitioned index/filters β index and filter blocks are themselves split into smaller sub-blocks, enabling partial caching
- Block cache β LRU or Clock cache; blocks are decompressed on read and cached in the block cache, not the OS page cache
- Column families β each CF has its own set of SSTables; a single DB can have multiple independent LSM trees sharing one WAL
- Ingestion β
IngestExternalFile()links a pre-built SSTable directly into the LSM tree without going through MemTable or compaction
Apache Cassandra (org.apache.cassandra.io.sstable)
Cassandraβs SSTable format has evolved across versions (ka/la/ma/mc/md/me).
Key additions over LevelDB:
- Partition index β two-level: a summary (in RAM) + partition index on disk
- Column index β for wide rows, an additional index within a partition
- Bloom filter β per-SSTable, checked before any disk read
- Statistics β min/max column values, tombstone counts, estimated cardinality β used by the query planner to skip SSTables
Apache Parquet (columnar SSTable format) Parquet stores data column by column instead of row by row. Layout:
Row group 0:
Column chunk: age [page0][page1]... <- only age values, compressed
Column chunk: city [page0][page1]...
Column chunk: name [page0][page1]...
Row group 1: ...
Footer: schema + row group metadata + column statistics
Magic: PAR1
A query SELECT age WHERE city = 'London' reads only the city and age
column chunks, skipping name entirely β predicate pushdown at the
storage layer. DuckDB, Apache Spark, Delta Lake, and Apache Iceberg all use
Parquet as their on-disk SSTable format.
Key design lesson: the SSTable is the universal unit of immutable sorted storage. Every LSM-family system β LevelDB, RocksDB, Cassandra, HBase, Badger β converges on the same structure: sorted records + sparse index + Bloom filter + footer with index offset. The only variation is whether records are row-oriented or column-oriented.
6. Min-Heap (K-way Merge)
Concept
Compaction reads K sorted input iterators (L0 SSTables + L1 SSTables) and produces one merged sorted output. A min-heap always contains the current (smallest remaining) element of each iterator. Each pop+advance costs O(log K), giving O(N log K) total for N records across K sources.
Initial heap (3 inputs):
Input A: age/1 city/7 name/3
Input B: city/2 name/1
Input C: city/6
Step 1: pop min = age/1 -> advance A -> emit age=25
heap: {city/7, city/2, city/6}
Step 2: pop min = city/7 -> advance A -> emit city=Paris (newest)
heap: {name/3, city/2, city/6}
Step 3: pop min = city/6 -> advance C -> SKIP (same user-key "city", already emitted)
heap: {name/3, city/2}
Step 4: pop min = city/2 -> advance B -> SKIP (same user-key "city")
heap: {name/3, name/1}
Step 5: pop min = name/3 -> advance A -> emit name=Bob (newest)
heap: {name/1}
Step 6: pop min = name/1 -> advance B -> SKIP (same user-key "name")
heap: {} -> done
Go implementation (lab06/iter.go)
type heapNode struct {
src rawIter
key []byte
value []byte
}
type nodeHeap []heapNode
func (h nodeHeap) Len() int { return len(h) }
func (h nodeHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h nodeHeap) Less(i, j int) bool {
return lab02.CompareInternal(h[i].key, h[j].key) < 0
}
func (h *nodeHeap) Push(x interface{}) { *h = append(*h, x.(heapNode)) }
func (h *nodeHeap) Pop() interface{} {
old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x
}
func (m *MergedIterator) next() {
for {
if m.h.Len() == 0 { m.valid = false; return }
top := heap.Pop(&m.h).(heapNode)
// Push the source's next element back into the heap.
top.src.Next()
if top.src.Valid() {
heap.Push(&m.h, heapNode{
src: top.src, key: top.src.Key(), value: top.src.Value(),
})
}
uk, seq, kt := lab02.DecodeInternalKey(top.key)
if seq > m.readSeq { continue } // future write
if bytes.Equal(uk, m.lastUserKey) { continue } // older dup
m.lastUserKey = uk
if kt == lab02.TypeDelete { continue } // tombstone
m.key, m.value, m.valid = top.key, top.value, true
return
}
}
Python implementation
import heapq
class _HeapEntry:
"""Comparable wrapper so heapq orders by CompareInternal."""
__slots__ = ('key', 'val', 'src')
def __init__(self, key, val, src): self.key=key; self.val=val; self.src=src
def __lt__(self, o): return compare_internal(self.key, o.key) < 0
def __eq__(self, o): return compare_internal(self.key, o.key) == 0
class MergedIterator:
"""K-way merge with MVCC dedup and tombstone suppression."""
def __init__(self, sources: list, read_seq: int = 2**64 - 1):
self._read_seq = read_seq
self._heap: list = []
for src in sources:
it = iter(src)
try:
ik, val = next(it)
heapq.heappush(self._heap, _HeapEntry(ik, val, it))
except StopIteration:
pass
def __iter__(self):
last_user_key: Optional[bytes] = None
while self._heap:
entry = heapq.heappop(self._heap)
try:
nik, nval = next(entry.src)
heapq.heappush(self._heap, _HeapEntry(nik, nval, entry.src))
except StopIteration:
pass
uk, seq, kt = decode_internal_key(entry.key)
if seq > self._read_seq: continue # future version
if uk == last_user_key: continue # older dup
last_user_key = uk
if kt == TYPE_DELETE: continue # tombstone
yield entry.key, entry.val
Example: compact three SSTables into one merged output
import tempfile
def make_sst(path, pairs):
"""pairs = [(user_key_str, seq, key_type, value_str)]"""
sorted_pairs = sorted(pairs,
key=lambda x: encode_internal_key(x[0].encode(), x[1], x[2]))
bld = SSTBuilder(path)
for uk, seq, kt, val in sorted_pairs:
bld.add(encode_internal_key(uk.encode(), seq, kt), val.encode())
bld.finish()
# L0 SSTable 0: newest writes
sst0 = tempfile.mktemp(suffix='.sst')
make_sst(sst0, [("city", 7, TYPE_VALUE, "Paris"), ("name", 5, TYPE_VALUE, "Bob")])
# L0 SSTable 1: earlier writes
sst1 = tempfile.mktemp(suffix='.sst')
make_sst(sst1, [("age", 1, TYPE_VALUE, "25"), ("city", 2, TYPE_VALUE, "London")])
# L1 SSTable: old data
sst2 = tempfile.mktemp(suffix='.sst')
make_sst(sst2, [("city", 6, TYPE_VALUE, "Berlin"), ("name", 3, TYPE_VALUE, "Alice")])
readers = [SSTReader(p) for p in [sst0, sst1, sst2]]
mi = MergedIterator(readers, read_seq=10)
print("Merged output (one entry per user-key, newest wins):")
for ik, val in mi:
uk, seq, kt = decode_internal_key(ik)
print(f" {uk.decode():8s} seq={seq} -> {val.decode()}")
Output:
Merged output (one entry per user-key, newest wins):
age seq=1 -> 25
city seq=7 -> Paris <- seq=6 and seq=2 deduplicated
name seq=5 -> Bob <- seq=3 deduplicated
This output is exactly what the new L1 SSTable contains after compaction.
In real database engines
LevelDB (db/version_set.cc, table/merger.cc)
MergingIterator wraps a MergeIterHeap (std::priority_queue) over
all child iterators. Compaction in DoCompactionWork() calls
input->key() / input->Next() in a loop β exactly the pattern above.
The dedup logic checks ikey.user_key == last_key and drops the older version.
kTypeDeletion entries are dropped only when all levels below the current
compaction level are empty (otherwise a delete could expose an older version).
RocksDB (table/merging_iterator.cc)
RocksDB uses a binary heap (same as above) but adds:
pinned_iters_mgrβ iterators can pin blocks in the block cache to avoid eviction while compaction is runningCompactionIterator(db/compaction/compaction_iterator.cc) β separate class that handles merge operators, range tombstones (FragmentedRangeTombstoneIterator), and TTL expiry on top of the raw K-way merge
PostgreSQL external merge sort (src/backend/utils/sort/tuplesort.c)
Postgres uses the same K-way merge for ORDER BY and index builds:
- Run formation β fill memory with tuples, sort in RAM (quicksort)
- Merge β
LogicalTapeSetcreates K sorted runs on disk; a replacement selection heap merges them into one output tape The heap holds the current minimum tuple from each tape, same structure as ournodeHeap.
Apache Spark (core/src/main/scala/org/apache/spark/util/collection/ExternalSorter.scala)
Spill files are individual sorted runs. At merge time Spark opens one
iterator per spill file and feeds them into a priority queue β identical
to our Python MergedIterator. The merged stream is written as the final
RDD partition or shuffle output file.
Apache Cassandra compaction strategies All three compaction strategies ultimately perform K-way merge but differ in which SSTables to merge:
- STCS (Size-Tiered): merge SSTables of similar size β fewest merges, highest space amp
- LCS (Leveled): merge one SSTable from L_n into overlapping SSTables in L_{n+1} β same algorithm as LevelDB
- TWCS (Time-Window): merge SSTables within a time window β optimized for time-series data where old windows are never written again
Key design lesson: the K-way merge heap is the inner loop of every
compaction and every external sort. Once you see it here, you recognize
it everywhere: database index builds, MapReduce shuffle merge phase, Parquet
file merge in Delta Lake OPTIMIZE, PostgreSQL CLUSTER command.
7. Red-Black Tree
Concept
A red-black tree is a self-balancing binary search tree (BST) with one extra bit per node: its color (red or black). Four invariants keep it balanced:
- Every node is red or black.
- The root is black.
- No two adjacent red nodes (a red nodeβs parent must be black).
- Every path from any node to a NIL leaf crosses the same number of black nodes.
These rules guarantee the treeβs height is at most $2\log_2(n+1)$, bounding all operations at O(log n) worst-case β unlike the skip listβs O(log n) expected time.
Insert order: "name", "age", "city"
After inserting "name" (root, forced black):
[name:B]
After inserting "age" (red, left child):
[name:B]
/
[age:R]
After inserting "city" (red):
Would make [age:R]β[city:R] β violates rule 3.
Left-rotate on "age", then right-rotate on "name", recolor:
[city:B]
/ \
[age:R] [name:R]
LevelDB comparison: LevelDB uses a skip list for its MemTable. A
red-black tree is the natural alternative: C++ std::map and Java TreeMap
use one; RocksDB optionally replaces the skip list with a hash skip list or
could use a BST variant. The key trade-off is concurrency β skip lists are
easier to make lock-free via CAS on individual next pointers, while
red-black tree rotations touch multiple nodes simultaneously.
Serialization
Red-black trees are always in-memory structures. To persist them:
Option A β sorted flat dump (what LevelDB does):
In-order traversal β varint-encoded records β identical to SSTable data section.
Color information is discarded; the tree is rebuilt from scratch on reload.
Option B β structural dump (for debugging / snapshots):
Per-node record:
[1B color: 0=black 1=red]
[8B left child file offset, 0xFF...FF = NIL]
[8B right child file offset, 0xFF...FF = NIL]
[2B keyLen][2B valLen][key bytes][val bytes]
File header: [8B root offset]
LevelDB takes Option A: the MemTable (skip list, but same idea) is flushed as a sorted SSTable β the in-memory sorted structure is discarded entirely.
Go implementation
type rbColor bool
const (
rbBlack rbColor = false
rbRed rbColor = true
)
type rbNode struct {
key, value []byte
color rbColor
left, right, parent *rbNode
}
type RBTree struct {
root *rbNode
nil_ *rbNode // sentinel NIL node (always black)
size int
}
func NewRBTree() *RBTree {
sentinel := &rbNode{color: rbBlack}
sentinel.left = sentinel
sentinel.right = sentinel
sentinel.parent = sentinel
return &RBTree{nil_: sentinel, root: sentinel}
}
func (t *RBTree) rotateLeft(x *rbNode) {
y := x.right
x.right = y.left
if y.left != t.nil_ {
y.left.parent = x
}
y.parent = x.parent
if x.parent == t.nil_ {
t.root = y
} else if x == x.parent.left {
x.parent.left = y
} else {
x.parent.right = y
}
y.left = x
x.parent = y
}
func (t *RBTree) rotateRight(x *rbNode) {
y := x.left
x.left = y.right
if y.right != t.nil_ {
y.right.parent = x
}
y.parent = x.parent
if x.parent == t.nil_ {
t.root = y
} else if x == x.parent.right {
x.parent.right = y
} else {
x.parent.left = y
}
y.right = x
x.parent = y
}
func (t *RBTree) Put(key, value []byte) {
z := &rbNode{
key: key, value: value, color: rbRed,
left: t.nil_, right: t.nil_, parent: t.nil_,
}
x, y := t.root, t.nil_
for x != t.nil_ {
y = x
c := bytes.Compare(key, x.key)
if c == 0 {
x.value = value // update in place
return
}
if c < 0 {
x = x.left
} else {
x = x.right
}
}
z.parent = y
if y == t.nil_ {
t.root = z
} else if bytes.Compare(key, y.key) < 0 {
y.left = z
} else {
y.right = z
}
t.fixInsert(z)
t.size++
}
func (t *RBTree) fixInsert(z *rbNode) {
for z.parent.color == rbRed {
if z.parent == z.parent.parent.left {
y := z.parent.parent.right
if y.color == rbRed { // Case 1: uncle red β recolor
z.parent.color = rbBlack
y.color = rbBlack
z.parent.parent.color = rbRed
z = z.parent.parent
} else {
if z == z.parent.right { // Case 2: uncle black, z is right child
z = z.parent
t.rotateLeft(z)
}
z.parent.color = rbBlack // Case 3: uncle black, z is left child
z.parent.parent.color = rbRed
t.rotateRight(z.parent.parent)
}
} else {
y := z.parent.parent.left
if y.color == rbRed {
z.parent.color = rbBlack
y.color = rbBlack
z.parent.parent.color = rbRed
z = z.parent.parent
} else {
if z == z.parent.left {
z = z.parent
t.rotateRight(z)
}
z.parent.color = rbBlack
z.parent.parent.color = rbRed
t.rotateLeft(z.parent.parent)
}
}
}
t.root.color = rbBlack
}
func (t *RBTree) Get(key []byte) ([]byte, bool) {
x := t.root
for x != t.nil_ {
c := bytes.Compare(key, x.key)
if c == 0 {
return x.value, true
}
if c < 0 {
x = x.left
} else {
x = x.right
}
}
return nil, false
}
// InOrder yields (key, value) sorted β same iteration contract as SkipList.
func (t *RBTree) InOrder(fn func(key, value []byte)) {
var walk func(*rbNode)
walk = func(n *rbNode) {
if n == t.nil_ {
return
}
walk(n.left)
fn(n.key, n.value)
walk(n.right)
}
walk(t.root)
}
// Serialize writes the sorted flat record format β identical to SSTable data section.
func (t *RBTree) Serialize(w io.Writer) error {
var err error
t.InOrder(func(key, value []byte) {
if err != nil {
return
}
var buf []byte
buf = appendVarint(buf, uint64(len(key)))
buf = append(buf, key...)
buf = appendVarint(buf, uint64(len(value)))
buf = append(buf, value...)
_, err = w.Write(buf)
})
return err
}
Python implementation
BLACK, RED = False, True
class _RBNode:
__slots__ = ('key', 'value', 'color', 'left', 'right', 'parent')
def __init__(self, key, value, color, nil):
self.key = key; self.value = value; self.color = color
self.left = self.right = self.parent = nil
class RBTree:
"""Self-balancing BST with O(log n) worst-case insert/lookup."""
def __init__(self):
self._nil = _RBNode(b'', b'', BLACK, None)
self._nil.left = self._nil.right = self._nil.parent = self._nil
self._root = self._nil
def _rotate_left(self, x):
y = x.right; x.right = y.left
if y.left is not self._nil: y.left.parent = x
y.parent = x.parent
if x.parent is self._nil: self._root = y
elif x is x.parent.left: x.parent.left = y
else: x.parent.right = y
y.left = x; x.parent = y
def _rotate_right(self, x):
y = x.left; x.left = y.right
if y.right is not self._nil: y.right.parent = x
y.parent = x.parent
if x.parent is self._nil: self._root = y
elif x is x.parent.right: x.parent.right = y
else: x.parent.left = y
y.right = x; x.parent = y
def put(self, key: bytes, value: bytes):
z = _RBNode(key, value, RED, self._nil)
y, x = self._nil, self._root
while x is not self._nil:
y = x
if key == x.key: x.value = value; return
x = x.left if key < x.key else x.right
z.parent = y
if y is self._nil: self._root = z
elif key < y.key: y.left = z
else: y.right = z
self._fix_insert(z)
def _fix_insert(self, z):
while z.parent.color == RED:
if z.parent is z.parent.parent.left:
y = z.parent.parent.right
if y.color == RED: # Case 1
z.parent.color = BLACK; y.color = BLACK
z.parent.parent.color = RED; z = z.parent.parent
else:
if z is z.parent.right: # Case 2
z = z.parent; self._rotate_left(z)
z.parent.color = BLACK # Case 3
z.parent.parent.color = RED
self._rotate_right(z.parent.parent)
else:
y = z.parent.parent.left
if y.color == RED:
z.parent.color = BLACK; y.color = BLACK
z.parent.parent.color = RED; z = z.parent.parent
else:
if z is z.parent.left:
z = z.parent; self._rotate_right(z)
z.parent.color = BLACK
z.parent.parent.color = RED
self._rotate_left(z.parent.parent)
self._root.color = BLACK
def get(self, key: bytes) -> Optional[bytes]:
x = self._root
while x is not self._nil:
if key == x.key: return x.value
x = x.left if key < x.key else x.right
return None
def __iter__(self):
"""In-order traversal β sorted, same contract as SkipList.__iter__."""
stack, cur = [], self._root
while stack or cur is not self._nil:
while cur is not self._nil:
stack.append(cur); cur = cur.left
cur = stack.pop()
yield cur.key, cur.value
cur = cur.right
def serialize(self) -> bytes:
"""Flat in-order varint record format β same layout as SSTable data section."""
out = bytearray()
for key, val in self:
out += encode_varint(len(key)) + key
out += encode_varint(len(val)) + val
return bytes(out)
Example: insert age/city/name, verify sort order, serialize
rbt = RBTree()
# Insert deliberately out of alphabetical order
rbt.put(encode_internal_key(b"name", 3, TYPE_VALUE), b"Alice")
rbt.put(encode_internal_key(b"age", 1, TYPE_VALUE), b"25")
rbt.put(encode_internal_key(b"city", 2, TYPE_VALUE), b"London")
print("In-order (identical result to SkipList):")
for ik, val in rbt:
uk, seq, kt = decode_internal_key(ik)
print(f" {uk.decode():8s} seq={seq} -> {val.decode()}")
blob = rbt.serialize()
print(f"\nSerialized {len(blob)} bytes β same layout as SSTable data section")
# Verify: the serialized bytes are identical to SSTBuilder output
# for the same three records written in the same order.
Output:
In-order (identical result to SkipList):
age seq=1 -> 25
city seq=2 -> London
name seq=3 -> Alice
Serialized 49 bytes β same layout as SSTable data section
Skip List vs Red-Black Tree
Skip List (lab02) | Red-Black Tree | |
|---|---|---|
| Insert complexity | O(log n) expected | O(log n) worst-case |
| Memory per node | ~3 pointers avg (level 1β2 typical) | 3 child/parent pointers + sentinel |
| Lock-free | Yes β CAS on next pointers | Hard β rotations touch 3+ nodes |
| Cache locality | Poor (pointer chasing) | Poor (same) |
| Serialization | In-order scan | In-order traversal |
| Used by | LevelDB, RocksDB MemTable | std::map, Java TreeMap, Linux rbtree |
In real database engines
Linux kernel (include/linux/rbtree.h, lib/rbtree.c)
The kernel ships a generic red-black tree used in dozens of subsystems:
| Subsystem | What is keyed | Source file |
|---|---|---|
| CFS scheduler | task vruntime (CPU fairness) | kernel/sched/fair.c |
| Virtual memory | vm_area_struct (VMA) regions | mm/mmap.c |
| epoll | file descriptor + interest events | fs/eventpoll.c |
| Ext4 extents | block range β physical block | fs/ext4/extents.c |
| Pipe inodes | inode number | fs/pipe.c |
The kernelβs rb_node embeds directly inside the data structure (no
separate allocation), accessed via container_of(). Colors are stored in
the LSB of the rb_parent_color pointer β exploiting the fact that
pointers are always 4-byte aligned, making the LSB always zero except for
the color bit.
struct rb_node {
unsigned long __rb_parent_color; // parent ptr | color in bit 0
struct rb_node *rb_right;
struct rb_node *rb_left;
};
glibc malloc (malloc/malloc.c)
Free chunks larger than FASTBIN_CONSOLIDATION_THRESHOLD (~64 KiB) are
tracked in a red-black tree keyed by chunk size. malloc(n) does a
rb_find_first_fit(size) β O(log n) β to find the smallest chunk β₯ n.
Smaller free chunks use segregated free lists (bins) indexed by size class,
which is why a benchmark allocating many same-sized objects is faster than
allocating varied sizes.
Java TreeMap / TreeSet (JDK java/util/TreeMap.java)
Javaβs TreeMap is a textbook red-black tree. RocksDBβs Java API uses a
TreeMap<InternalKey, byte[]> in some of its in-memory test stubs, and
Cassandraβs ConcurrentSkipListMap is the production alternative that
trades worst-case guarantees for better concurrent scalability.
Nginx timer wheel (src/event/ngx_event_timer.c)
Nginx stores all pending I/O timeouts in a red-black tree keyed by
expiry time (milliseconds). ngx_event_find_timer() peeks at the leftmost
node (minimum expiry) in O(1); ngx_event_expire_timers() pops expired
events in order. This is functionally identical to a priority queue but
with O(log n) cancellation (removing an arbitrary node) instead of O(n).
Key design lesson: the red-black tree is the kernel/systems programmerβs choice whenever you need a sorted container with O(log n) worst-case and the ability to delete an arbitrary node by pointer (not just the minimum). A heap gives you O(1) find-min but O(n) arbitrary delete; a skip list gives you O(log n) expected but poor cache behavior; only the red-black tree gives O(log n) worst-case insert + delete + arbitrary-key lookup with compact memory (3 pointers + 1 bit per node).
8. B-Tree
Concept
A B-tree of order m is a balanced m-way search tree where:
- Every non-root node holds between βm/2β and mβ1 keys.
- An internal node with k keys has k+1 children.
- All leaves are at the same depth.
- Both internal nodes and leaves store values β a search can terminate at any level.
B-tree (order 3, max 2 keys/node) after inserting age, city, name:
ββββββββββββββββ
β "city"="Lon" β β value lives right here in the internal node
ββββββββββββββββ
/ \
ββββββββββββββββ ββββββββββββββββ
β "age" = "25" β β"name"="Alice"β
ββββββββββββββββ ββββββββββββββββ
Relation to this repo: The option-a-sqlite and option-b-sqlite labs use
SQLite as their backend. SQLite stores every table row in a B-tree page β
sqlite3BtreeInsert encodes the row as a cell and inserts it into the
appropriate node, potentially splitting pages.
B-Tree vs LSM trade-offs:
| B-Tree (SQLite backend) | LSM Tree (LevelDB) | |
|---|---|---|
| Read | O(log n), warm page cache | O(levels Γ log n) |
| Write | Random I/O β update in place | Sequential I/O β append-only |
| Write amplification | ~2β3Γ | ~10β30Γ |
| Space amplification | Low (no stale versions) | Medium (until compaction) |
| Crash safety | Page journaling or WAL | WAL + immutable SSTables |
Serialization: fixed-size pages
Each B-tree node maps to one page (typically 4096 bytes):
Leaf page:
[1B: type = 0x02]
[2B: numKeys, little-endian]
per record: [2B keyLen][2B valLen][key bytes][val bytes]
Internal page:
[1B: type = 0x01]
[2B: numKeys]
per key: [2B keyLen][2B valLen][key bytes][val bytes] β value stored here too
per child: [8B child page ID, little-endian] β numKeys+1 entries
File header (page 0):
[8B magic = 0xB77EEB77EEB77EEB]
[8B root page ID]
[8B total page count]
Go implementation
const (
btOrder = 3 // max keys per node = 2*btOrder - 1 = 5
btPageSize = 4096
btMagic = uint64(0xB77EEB77EEB77EEB)
btLeaf = byte(0x02)
btInternal = byte(0x01)
)
type btNode struct {
keys [][]byte
values [][]byte // parallel to keys in both leaf and internal nodes
children []*btNode // len = len(keys)+1 for internal; nil for leaf
isLeaf bool
}
type BTree struct{ root *btNode }
func NewBTree() *BTree { return &BTree{root: &btNode{isLeaf: true}} }
func (t *BTree) Get(key []byte) ([]byte, bool) {
return t.search(t.root, key)
}
func (t *BTree) search(n *btNode, key []byte) ([]byte, bool) {
i := 0
for i < len(n.keys) && bytes.Compare(key, n.keys[i]) > 0 {
i++
}
if i < len(n.keys) && bytes.Equal(key, n.keys[i]) {
return n.values[i], true // found at this node (leaf or internal)
}
if n.isLeaf {
return nil, false
}
return t.search(n.children[i], key)
}
func (t *BTree) Put(key, value []byte) {
root := t.root
if len(root.keys) == 2*btOrder-1 {
newRoot := &btNode{isLeaf: false, children: []*btNode{root}}
t.splitChild(newRoot, 0)
t.root = newRoot
t.insertNonFull(newRoot, key, value)
} else {
t.insertNonFull(root, key, value)
}
}
func (t *BTree) insertNonFull(n *btNode, key, value []byte) {
i := len(n.keys) - 1
if n.isLeaf {
n.keys = append(n.keys, nil)
n.values = append(n.values, nil)
for i >= 0 && bytes.Compare(key, n.keys[i]) < 0 {
n.keys[i+1] = n.keys[i]
n.values[i+1] = n.values[i]
i--
}
// Update existing key
if i >= 0 && bytes.Equal(key, n.keys[i]) {
n.values[i] = value
n.keys = n.keys[:len(n.keys)-1]
n.values = n.values[:len(n.values)-1]
return
}
n.keys[i+1] = key
n.values[i+1] = value
} else {
for i >= 0 && bytes.Compare(key, n.keys[i]) < 0 {
i--
}
i++
if len(n.children[i].keys) == 2*btOrder-1 {
t.splitChild(n, i)
if bytes.Compare(key, n.keys[i]) > 0 {
i++
}
}
t.insertNonFull(n.children[i], key, value)
}
}
func (t *BTree) splitChild(parent *btNode, i int) {
mid := btOrder - 1
child := parent.children[i]
sib := &btNode{isLeaf: child.isLeaf}
sib.keys = append(sib.keys, child.keys[mid+1:]...)
sib.values = append(sib.values, child.values[mid+1:]...)
if !child.isLeaf {
sib.children = append(sib.children, child.children[mid+1:]...)
child.children = child.children[:mid+1]
}
// Promote median key to parent
parent.keys = append(parent.keys, nil)
parent.values = append(parent.values, nil)
parent.children = append(parent.children, nil)
copy(parent.keys[i+1:], parent.keys[i:])
copy(parent.values[i+1:], parent.values[i:])
copy(parent.children[i+2:], parent.children[i+1:])
parent.keys[i] = child.keys[mid]
parent.values[i] = child.values[mid]
parent.children[i+1] = sib
child.keys = child.keys[:mid]
child.values = child.values[:mid]
}
// InOrder yields (key, value) sorted β visits internal nodes too.
func (t *BTree) InOrder(fn func(k, v []byte)) { btInOrder(t.root, fn) }
func btInOrder(n *btNode, fn func(k, v []byte)) {
if n.isLeaf {
for i, k := range n.keys { fn(k, n.values[i]) }
return
}
for i, k := range n.keys {
btInOrder(n.children[i], fn)
fn(k, n.values[i])
}
btInOrder(n.children[len(n.keys)], fn)
}
// SerializePage encodes one node into a btPageSize-byte buffer.
func (t *BTree) SerializePage(n *btNode) []byte {
page := make([]byte, btPageSize)
if n.isLeaf { page[0] = btLeaf } else { page[0] = btInternal }
binary.LittleEndian.PutUint16(page[1:3], uint16(len(n.keys)))
off := 3
for idx, k := range n.keys {
binary.LittleEndian.PutUint16(page[off:], uint16(len(k))); off += 2
copy(page[off:], k); off += len(k)
v := n.values[idx]
binary.LittleEndian.PutUint16(page[off:], uint16(len(v))); off += 2
copy(page[off:], v); off += len(v)
}
if !n.isLeaf {
// child page IDs β in a file-backed tree these are uint64 page numbers
for range n.children {
binary.LittleEndian.PutUint64(page[off:], 0 /*placeholder*/); off += 8
}
}
return page
}
Python implementation
BTREE_ORDER = 3 # max keys per node = 2*ORDER - 1 = 5
BTREE_PAGE = 4096
BTREE_MAGIC = 0xB77EEB77EEB77EEB
BT_LEAF = 0x02
BT_INTERNAL = 0x01
class _BTNode:
__slots__ = ('keys', 'values', 'children', 'is_leaf')
def __init__(self, is_leaf=True):
self.keys: list[bytes] = []
self.values: list[bytes] = [] # parallel to keys at every level
self.children: list['_BTNode'] = []
self.is_leaf = is_leaf
class BTree:
"""B-tree (order 3): values stored in every node, not just leaves."""
def __init__(self):
self._root = _BTNode(is_leaf=True)
def get(self, key: bytes) -> Optional[bytes]:
return self._search(self._root, key)
def _search(self, n: _BTNode, key: bytes) -> Optional[bytes]:
i = 0
while i < len(n.keys) and key > n.keys[i]: i += 1
if i < len(n.keys) and key == n.keys[i]:
return n.values[i]
if n.is_leaf: return None
return self._search(n.children[i], key)
def put(self, key: bytes, value: bytes):
root = self._root
if len(root.keys) == 2 * BTREE_ORDER - 1:
new_root = _BTNode(is_leaf=False)
new_root.children.append(root)
self._split_child(new_root, 0)
self._root = new_root
self._insert_non_full(self._root, key, value)
def _insert_non_full(self, n: _BTNode, key: bytes, value: bytes):
i = len(n.keys) - 1
if n.is_leaf:
n.keys.append(b''); n.values.append(b'')
while i >= 0 and key < n.keys[i]:
n.keys[i+1] = n.keys[i]; n.values[i+1] = n.values[i]; i -= 1
if i >= 0 and key == n.keys[i]:
n.values[i] = value # update existing
n.keys.pop(); n.values.pop()
return
n.keys[i+1] = key; n.values[i+1] = value
else:
while i >= 0 and key < n.keys[i]: i -= 1
i += 1
if len(n.children[i].keys) == 2 * BTREE_ORDER - 1:
self._split_child(n, i)
if key > n.keys[i]: i += 1
self._insert_non_full(n.children[i], key, value)
def _split_child(self, parent: _BTNode, i: int):
ORDER = BTREE_ORDER
child = parent.children[i]
mid = ORDER - 1
sib = _BTNode(is_leaf=child.is_leaf)
sib.keys = child.keys[mid+1:]
sib.values = child.values[mid+1:]
if not child.is_leaf:
sib.children = child.children[ORDER:]
child.children = child.children[:ORDER]
parent.keys.insert(i, child.keys[mid])
parent.values.insert(i, child.values[mid])
parent.children.insert(i+1, sib)
child.keys = child.keys[:mid]
child.values = child.values[:mid]
def __iter__(self):
"""In-order traversal β visits keys in sorted order."""
yield from self._inorder(self._root)
def _inorder(self, n: _BTNode):
if n.is_leaf:
yield from zip(n.keys, n.values)
else:
for i, k in enumerate(n.keys):
yield from self._inorder(n.children[i])
yield k, n.values[i]
yield from self._inorder(n.children[-1])
def serialize_page(self, n: _BTNode) -> bytes:
"""Fixed 4096-byte page encoding for one B-tree node."""
buf = bytearray(BTREE_PAGE)
buf[0] = BT_LEAF if n.is_leaf else BT_INTERNAL
struct.pack_into('<H', buf, 1, len(n.keys))
off = 3
for k, v in zip(n.keys, n.values):
struct.pack_into('<HH', buf, off, len(k), len(v)); off += 4
buf[off:off+len(k)] = k; off += len(k)
buf[off:off+len(v)] = v; off += len(v)
if not n.is_leaf:
for _ in n.children: # placeholder child page IDs
struct.pack_into('<Q', buf, off, 0); off += 8
return bytes(buf)
Example: insert, look up, inspect page bytes
bt = BTree()
bt.put(b"name", b"Alice")
bt.put(b"age", b"25")
bt.put(b"city", b"London")
print("In-order traversal:")
for k, v in bt:
print(f" {k.decode():8s} -> {v.decode()}")
print(f"\nRoot is_leaf : {bt._root.is_leaf}")
print(f"Root keys : {[k.decode() for k in bt._root.keys]}")
page = bt.serialize_page(bt._root)
print(f"\nPage size : {len(page)} bytes (always fixed)")
print(f"type byte : {page[0]:#x} (0x02 = leaf)")
print(f"numKeys : {struct.unpack_from('<H', page, 1)[0]}")
Output:
In-order traversal:
age -> 25
city -> London
name -> Alice
Root is_leaf : True
Root keys : ['age', 'city', 'name']
Page size : 4096 bytes (always fixed)
type byte : 0x2 (0x02 = leaf)
numKeys : 3
In real database engines
SQLite (src/btree.c, src/btreeInt.h)
Every SQLite table is stored as a B-tree of pages (default 4096 bytes).
SQLite distinguishes two page types:
- Table B-tree (rowid table) β leaves store the full row payload; internal nodes store only the key (rowid) and child page numbers. This is actually closer to a B+ tree for the data, but SQLiteβs source calls it a B-tree because overflow values can chain across pages.
- Index B-tree β leaves store the index key + rowid; no separate
value column. Used for
CREATE INDEXstatements.
Page header (first 8β12 bytes of each page):
[1B page type: 0x02=leaf-index 0x05=leaf-table 0x0a=interior-index 0x0d=interior-table]
[2B first freeblock offset]
[2B number of cells on page]
[2B start of cell content area]
[1B fragmented free bytes]
[4B rightmost child page (interior pages only)]
sqlite3BtreeInsert() in btree.c finds the leaf page via moveToChild(),
inserts the cell, and calls balance() to split if the page overflows β
the same split logic as our _split_child above.
CouchDB (src/couch_btree.erl)
CouchDB uses an append-only B-tree: it never overwrites existing pages.
Every modification writes new pages at the end of the database file and
updates the root pointer in the file header. Old page versions remain until
a compaction rewrites the entire file. This gives CouchDB crash safety
without a WAL β the file is always consistent at the last committed root
pointer β at the cost of unbounded file growth between compactions.
Oracle Index-Organized Tables (IOT)
In a regular Oracle table, rows live in a heap file and the B-tree index
stores (index_key β rowid β heap lookup) β two I/Os per point lookup.
An IOT stores the entire row in the B-tree leaf node, eliminating the
second I/O. The primary key is the B-tree key; secondary indexes store the
primary key value (not a rowid) as the pointer, making them slightly larger
but immune to row movement during reorganization. This is identical to
InnoDBβs clustered index design (see B+ Tree section).
Key design lesson: pure B-trees (values in every node) appear in embedded/single-file databases (SQLite, CouchDB) because simplicity matters more than range-scan performance. Once range scans become a first-class workload β which they do in every OLTP system β engines add the leaf linked list and become B+ trees.
9. B+ Tree
Concept
A B+ tree is a B-tree variant with one critical difference:
- Internal (routing) nodes store only keys β no values.
- All values live exclusively in leaf nodes.
- Leaf nodes are doubly-linked β enabling O(k) sequential range scans after an O(log n) index seek.
B+ tree (order 3) after inserting age, city, email, name, zip:
ββββββββββββ
β "name" β β routing key only, no value
ββββββββββββ
/ \
ββββββββββββββββββββββ ββββββββββββββββββ
β age=25 city=Lon ββββΊβ name=Alice β
β email=a@b.com β β zip=EC1A β
ββββββββββββββββββββββ ββββββββββββββββββ
leaf 0 βprev=nil leaf 1 βnext=nil
Relation to this repo and FoundationDB:
The LevelDB SSTable in lab04/sstable.go is structurally a one-level B+ tree:
SSTable layout B+ tree equivalent
βββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββ
data section (sorted records) leaf pages (all values here)
index section (key β offset) one internal routing node
16-byte footer file header with root page ID
varint variable-length fixed 4096-byte pages
immutable after Finish() copy-on-write (Redwood engine)
FoundationDBβs Redwood storage engine uses a full multi-level copy-on-write B+ tree over a page store, providing crash safety without an explicit WAL on the tree pages themselves.
Serialization: two page types
Internal page:
[1B: type = 0x01]
[2B: numKeys, LE]
per key: [2B keyLen][key bytes] β NO values in internal nodes
per child: [8B child page ID, LE] β numKeys+1 entries
Leaf page:
[1B: type = 0x02]
[2B: numKeys, LE]
[8B: prev leaf page ID] (0xFF...FF = none)
[8B: next leaf page ID] (0xFF...FF = none)
per record: [2B keyLen][2B valLen][key bytes][val bytes]
File header (page 0):
[8B: magic = 0xB99EEF15B99EEF15]
[8B: root page ID]
[8B: total page count]
Go implementation
const (
bpOrder = 3
bpPageSize = 4096
bpMagic = uint64(0xB99EEF15B99EEF15)
bpInternal = byte(0x01)
bpLeaf = byte(0x02)
bpNilPage = ^uint64(0) // 0xFFFFFFFFFFFFFFFF
)
type bpNode struct {
keys [][]byte
values [][]byte // leaf only
children []*bpNode // internal only; len = len(keys)+1
next *bpNode // leaf linked list β
prev *bpNode // leaf linked list β
isLeaf bool
}
type BPlusTree struct {
root *bpNode
firstLeaf *bpNode
}
func NewBPlusTree() *BPlusTree {
leaf := &bpNode{isLeaf: true}
return &BPlusTree{root: leaf, firstLeaf: leaf}
}
func (t *BPlusTree) findLeaf(key []byte) *bpNode {
n := t.root
for !n.isLeaf {
i := 0
for i < len(n.keys) && bytes.Compare(key, n.keys[i]) >= 0 {
i++
}
n = n.children[i]
}
return n
}
func (t *BPlusTree) Get(key []byte) ([]byte, bool) {
leaf := t.findLeaf(key)
for i, k := range leaf.keys {
if bytes.Equal(k, key) {
return leaf.values[i], true
}
}
return nil, false
}
// RangeScan yields (key, value) for lo β€ key β€ hi using the leaf linked list.
func (t *BPlusTree) RangeScan(lo, hi []byte, fn func(key, value []byte)) {
leaf := t.findLeaf(lo)
for leaf != nil {
for i, k := range leaf.keys {
if bytes.Compare(k, lo) < 0 { continue }
if bytes.Compare(k, hi) > 0 { return }
fn(k, leaf.values[i])
}
leaf = leaf.next
}
}
func (t *BPlusTree) Put(key, value []byte) {
leaf := t.findLeaf(key)
for i, k := range leaf.keys {
if bytes.Equal(k, key) { leaf.values[i] = value; return }
}
i := sort.Search(len(leaf.keys), func(j int) bool {
return bytes.Compare(leaf.keys[j], key) >= 0
})
leaf.keys = append(leaf.keys, nil)
leaf.values = append(leaf.values, nil)
copy(leaf.keys[i+1:], leaf.keys[i:])
copy(leaf.values[i+1:], leaf.values[i:])
leaf.keys[i] = key
leaf.values[i] = value
if len(leaf.keys) > 2*bpOrder-1 {
t.splitLeaf(leaf)
}
}
func (t *BPlusTree) splitLeaf(leaf *bpNode) {
mid := len(leaf.keys) / 2
newLeaf := &bpNode{isLeaf: true}
newLeaf.keys = append(newLeaf.keys, leaf.keys[mid:]...)
newLeaf.values = append(newLeaf.values, leaf.values[mid:]...)
leaf.keys = leaf.keys[:mid]
leaf.values = leaf.values[:mid]
// Stitch linked list
newLeaf.next = leaf.next
newLeaf.prev = leaf
if leaf.next != nil { leaf.next.prev = newLeaf }
leaf.next = newLeaf
t.insertIntoParent(leaf, newLeaf.keys[0], newLeaf)
}
func (t *BPlusTree) insertIntoParent(left *bpNode, key []byte, right *bpNode) {
if left == t.root {
newRoot := &bpNode{
isLeaf: false,
keys: [][]byte{key},
children: []*bpNode{left, right},
}
t.root = newRoot
return
}
parent := t.findParent(t.root, left)
i := 0
for i < len(parent.children) && parent.children[i] != left { i++ }
parent.keys = append(parent.keys, nil)
parent.children = append(parent.children, nil)
copy(parent.keys[i+1:], parent.keys[i:])
copy(parent.children[i+2:], parent.children[i+1:])
parent.keys[i] = key
parent.children[i+1] = right
if len(parent.keys) > 2*bpOrder-1 {
t.splitInternal(parent)
}
}
func (t *BPlusTree) splitInternal(n *bpNode) {
mid := len(n.keys) / 2
push := n.keys[mid]
sib := &bpNode{isLeaf: false}
sib.keys = append(sib.keys, n.keys[mid+1:]...)
sib.children = append(sib.children, n.children[mid+1:]...)
n.keys = n.keys[:mid]
n.children = n.children[:mid+1]
if n == t.root {
newRoot := &bpNode{
isLeaf: false,
keys: [][]byte{push},
children: []*bpNode{n, sib},
}
t.root = newRoot
return
}
t.insertIntoParent(n, push, sib)
}
func (t *BPlusTree) findParent(cur, target *bpNode) *bpNode {
if cur.isLeaf { return nil }
for _, child := range cur.children {
if child == target { return cur }
if p := t.findParent(child, target); p != nil { return p }
}
return nil
}
// SerializeLeafPage encodes a leaf node into bpPageSize bytes.
func SerializeLeafPage(n *bpNode, prevID, nextID uint64) []byte {
page := make([]byte, bpPageSize)
page[0] = bpLeaf
binary.LittleEndian.PutUint16(page[1:3], uint16(len(n.keys)))
binary.LittleEndian.PutUint64(page[3:11], prevID)
binary.LittleEndian.PutUint64(page[11:19], nextID)
off := 19
for i, k := range n.keys {
binary.LittleEndian.PutUint16(page[off:], uint16(len(k))); off += 2
copy(page[off:], k); off += len(k)
v := n.values[i]
binary.LittleEndian.PutUint16(page[off:], uint16(len(v))); off += 2
copy(page[off:], v); off += len(v)
}
return page
}
// SerializeInternalPage encodes a routing node (keys only, no values).
func SerializeInternalPage(n *bpNode, childPageIDs []uint64) []byte {
page := make([]byte, bpPageSize)
page[0] = bpInternal
binary.LittleEndian.PutUint16(page[1:3], uint16(len(n.keys)))
off := 3
for _, k := range n.keys {
binary.LittleEndian.PutUint16(page[off:], uint16(len(k))); off += 2
copy(page[off:], k); off += len(k)
}
for _, id := range childPageIDs {
binary.LittleEndian.PutUint64(page[off:], id); off += 8
}
return page
}
Python implementation
BP_ORDER = 3
BP_PAGE = 4096
BP_MAGIC = 0xB99EEF15B99EEF15
BP_INTERNAL = 0x01
BP_LEAF = 0x02
BP_NIL = 0xFFFFFFFFFFFFFFFF
class _BPNode:
__slots__ = ('keys', 'values', 'children', 'next', 'prev', 'is_leaf')
def __init__(self, is_leaf=True):
self.keys: list[bytes] = []
self.values: list[bytes] = [] # leaf only
self.children: list['_BPNode'] = [] # internal only
self.next: Optional['_BPNode'] = None
self.prev: Optional['_BPNode'] = None
self.is_leaf = is_leaf
class BPlusTree:
"""B+ tree: values in leaves only; leaf linked-list for range scans."""
def __init__(self):
leaf = _BPNode(is_leaf=True)
self._root = leaf
self._first_leaf = leaf
def _find_leaf(self, key: bytes) -> _BPNode:
n = self._root
while not n.is_leaf:
i = 0
while i < len(n.keys) and key >= n.keys[i]: i += 1
n = n.children[i]
return n
def get(self, key: bytes) -> Optional[bytes]:
leaf = self._find_leaf(key)
for i, k in enumerate(leaf.keys):
if k == key: return leaf.values[i]
return None
def put(self, key: bytes, value: bytes):
leaf = self._find_leaf(key)
for i, k in enumerate(leaf.keys):
if k == key: leaf.values[i] = value; return
i = 0
while i < len(leaf.keys) and key > leaf.keys[i]: i += 1
leaf.keys.insert(i, key); leaf.values.insert(i, value)
if len(leaf.keys) > 2 * BP_ORDER - 1:
self._split_leaf(leaf)
def _split_leaf(self, leaf: _BPNode):
mid = len(leaf.keys) // 2
new_leaf = _BPNode(is_leaf=True)
new_leaf.keys = leaf.keys[mid:]
new_leaf.values = leaf.values[mid:]
leaf.keys = leaf.keys[:mid]
leaf.values = leaf.values[:mid]
new_leaf.next = leaf.next
new_leaf.prev = leaf
if leaf.next: leaf.next.prev = new_leaf
leaf.next = new_leaf
self._insert_into_parent(leaf, new_leaf.keys[0], new_leaf)
def _insert_into_parent(self, left: _BPNode, key: bytes, right: _BPNode):
if left is self._root:
r = _BPNode(is_leaf=False)
r.keys = [key]; r.children = [left, right]
self._root = r; return
parent = self._find_parent(self._root, left)
i = parent.children.index(left)
parent.keys.insert(i, key)
parent.children.insert(i + 1, right)
if len(parent.keys) > 2 * BP_ORDER - 1:
self._split_internal(parent)
def _split_internal(self, n: _BPNode):
mid = len(n.keys) // 2
push = n.keys[mid]
sib = _BPNode(is_leaf=False)
sib.keys = n.keys[mid+1:]
sib.children = n.children[mid+1:]
n.keys = n.keys[:mid]
n.children = n.children[:mid+1]
if n is self._root:
r = _BPNode(is_leaf=False)
r.keys = [push]; r.children = [n, sib]
self._root = r; return
self._insert_into_parent(n, push, sib)
def _find_parent(self, cur: _BPNode, target: _BPNode) -> Optional[_BPNode]:
if cur.is_leaf: return None
for child in cur.children:
if child is target: return cur
p = self._find_parent(child, target)
if p: return p
return None
def range_scan(self, lo: bytes, hi: bytes):
"""O(log n + k) range scan using leaf linked list."""
leaf = self._find_leaf(lo)
while leaf is not None:
for i, k in enumerate(leaf.keys):
if k < lo: continue
if k > hi: return
yield k, leaf.values[i]
leaf = leaf.next
def __iter__(self):
"""Full scan via leaf linked list β O(n), no tree traversal needed."""
leaf = self._first_leaf
while leaf is not None:
yield from zip(leaf.keys, leaf.values)
leaf = leaf.next
def serialize_leaf(self, n: _BPNode,
prev_id: int = BP_NIL,
next_id: int = BP_NIL) -> bytes:
buf = bytearray(BP_PAGE)
buf[0] = BP_LEAF
struct.pack_into('<H', buf, 1, len(n.keys))
struct.pack_into('<Q', buf, 3, prev_id)
struct.pack_into('<Q', buf, 11, next_id)
off = 19
for k, v in zip(n.keys, n.values):
struct.pack_into('<HH', buf, off, len(k), len(v)); off += 4
buf[off:off+len(k)] = k; off += len(k)
buf[off:off+len(v)] = v; off += len(v)
return bytes(buf)
def serialize_internal(self, n: _BPNode,
child_ids: list[int]) -> bytes:
"""Internal page stores keys only β no values."""
buf = bytearray(BP_PAGE)
buf[0] = BP_INTERNAL
struct.pack_into('<H', buf, 1, len(n.keys))
off = 3
for k in n.keys:
struct.pack_into('<H', buf, off, len(k)); off += 2
buf[off:off+len(k)] = k; off += len(k)
for cid in child_ids:
struct.pack_into('<Q', buf, off, cid); off += 8
return bytes(buf)
Example: insert age/city/name + extras, range scan, serialize pages
bpt = BPlusTree()
bpt.put(b"age", b"25")
bpt.put(b"city", b"London")
bpt.put(b"email", b"alice@example.com")
bpt.put(b"name", b"Alice")
bpt.put(b"zip", b"EC1A")
print("All keys (leaf linked-list, O(n) scan):")
for k, v in bpt:
print(f" {k.decode():8s} -> {v.decode()}")
print("\nRange scan [city .. name]:")
for k, v in bpt.range_scan(b"city", b"name"):
print(f" {k.decode():8s} -> {v.decode()}")
# Serialize leaf 0
leaf0 = bpt._first_leaf
leaf1_id = 1 if leaf0.next else BP_NIL
page0 = bpt.serialize_leaf(leaf0, prev_id=BP_NIL, next_id=leaf1_id)
print(f"\nLeaf page 0: {len(page0)} bytes")
print(f" type = {page0[0]:#x} (0x02 = leaf)")
print(f" numKeys = {struct.unpack_from('<H', page0, 1)[0]}")
print(f" prevID = {struct.unpack_from('<Q', page0, 3)[0]:#x}")
print(f" nextID = {struct.unpack_from('<Q', page0, 11)[0]:#x}")
Output:
All keys (leaf linked-list, O(n) scan):
age -> 25
city -> London
email -> alice@example.com
name -> Alice
zip -> EC1A
Range scan [city .. name]:
city -> London
email -> alice@example.com
name -> Alice
Leaf page 0: 4096 bytes
type = 0x2 (0x02 = leaf)
numKeys = 2
prevID = 0xffffffffffffffff
nextID = 0x1
B+ Tree β LevelDB SSTable equivalence
SSTable (lab04/sstable.go) B+ Tree
βββββββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββ
varint-encoded data records leaf page records (fixed-size pages)
sorted by CompareInternal sorted by Compare/CompareInternal
index: (key β file offset) array internal page: (key β child page ID)
16-byte footer: indexOffset + magic file header: root page ID + magic
immutable after Finish() + fdatasync copy-on-write pages (Redwood engine)
flushed whole from MemTable built incrementally, split on overflow
FoundationDBβs Redwood engine extends the SSTable idea to a full multi-level B+ tree with copy-on-write pages, giving crash safety without a WAL on the tree pages themselves β only the set of committed page replacements is journaled.
In real database engines
InnoDB (storage/innobase/btr/, storage/innobase/page/)
InnoDB is the reference B+ tree database engine. Every table has a
clustered index β the primary key B+ tree whose leaves contain the full
row β plus zero or more secondary indexes whose leaves contain
(secondary_key, primary_key). Key details:
- Page size: 16 KiB (configurable to 4/8/32/64 KiB in MySQL 8.0)
- Page type:
FIL_PAGE_INDEX(0x45BF) - Page header includes:
PAGE_LEVEL(0 = leaf),PAGE_N_RECS,PAGE_PREVandPAGE_NEXT(the leaf linked list β same as ourprev/nextpointers) - Records within a page are stored as a singly-linked list with a page directory (sparse array of slot offsets) for O(log n) binary search within the page
- Split policy: fill factor ~15/16 for sequential inserts; ~1/2 for random inserts (to leave room for future inserts and reduce immediate re-splits)
btr_cur_search_to_nth_level()β the core tree descent function inbtr0cur.cc, analogous to our_find_leaf()
PostgreSQL nbtree (src/backend/access/nbtree/)
Postgres implements B+ trees in the nbtree access method:
- Page size: 8 KiB (same as heap pages)
BTPageOpaqueDataappended to every page:btpo_prev,btpo_next(leaf sibling links),btpo_level,btpo_flags- High keys: the rightmost key of each non-rightmost page is stored as a special βhigh keyβ item β used to detect concurrent page splits without locks
- Concurrent modifications:
nbtinsert.cuses a stacked-latch protocol (hold parent latch while descending) rather than a global tree lock - Index-only scans: if all queried columns are in the index, Postgres can return data directly from the B+ tree leaf without touching the heap
MongoDB WiredTiger (src/third_party/wiredtiger/src/btree/)
WiredTiger provides both a row-store B+ tree and a column-store B+ tree:
WT_PAGEstruct inwt_internal.h:pg_intl_*fields for internal pages,pg_row_*for leaf row-store pages- Reconciliation: dirty in-memory pages are written to disk during checkpoints (analogous to LevelDB compaction writing new SSTables)
- Eviction: a background eviction thread maintains a memory budget by writing dirty pages and discarding clean ones
- MVCC via update chains: instead of a sequence number in the key,
WiredTiger chains
WT_UPDATEstructs off each leaf record β the read timestamp determines which update is visible
FoundationDB Redwood (fdbserver/KeyValueStoreRedwood.actor.cpp)
Redwood is a copy-on-write B+ tree purpose-built for FoundationDB:
BTreePagestruct with variable-length delta-encoded records (each key stores only the bytes that differ from the previous key β same idea as LevelDBβs prefix compression in data blocks)- Every write produces new page versions at affected leaf β up to root; the old pages remain accessible to concurrent readers at older versions (MVCC without a separate undo log)
- A pager (
DWALPager) under Redwood provides atomic page replacement: it journals the set of {old page ID β new page ID} mappings before committing them, providing the durability guarantee without a full WAL
Filesystem B+ trees
| Filesystem | Structure | Details |
|---|---|---|
| ext4 | htree (dir_index) | 2-level hash tree over directory entries; dx_root + dx_node structs in fs/ext4/namei.c |
| APFS | Object Map + B-tree | Each volume has a B-tree mapping object ID β physical block; apfs_btree_node_phys struct |
| NTFS | $INDEX_ALLOCATION | B+ tree of directory entries; INDEX_BLOCK pages with sibling links |
| HFS+ | Catalog File | B*-tree (nodes redistributed before split, higher fill factor than B+) |
Key design lesson: B+ trees dominate on-disk indexes because the leaf
linked list turns a B-tree (good for point lookups) into a structure that is
also optimal for range scans β the most common database workload. Every
component of the page format we designed above (type byte, numKeys,
prev/next page IDs, per-record keyLen+valLen) appears verbatim in
InnoDB, Postgres, and WiredTiger. The only variation is page size and
whether delta/prefix encoding is applied to records within a page.
Summary: all data structures compared
| Structure | Height | Insert | Point Lookup | Range Scan | Serialization on disk | Used in this repo |
|---|---|---|---|---|---|---|
| Skip List | O(log n) expected | O(log n) expected | O(log n) expected | O(k) forward | Flat sorted dump | LevelDB MemTable (lab02) |
| Red-Black Tree | β€ 2 logβ(n+1) | O(log n) worst-case | O(log n) worst-case | O(k) in-order | Flat sorted dump | Equivalent MemTable substitute |
| B-Tree | log_m(n) | O(log n) | O(log n) | O(kΒ·m) | Fixed-size pages | SQLite backend (option-a-sqlite, option-b-sqlite) |
| B+ Tree | log_m(n) | O(log n) | O(log n) | O(log n + k) | Fixed pages + leaf links | SSTable (lab04), FDB Redwood |
| Min-Heap | log n | O(log n) push | O(1) peek | N/A (ephemeral) | Not persisted | Compaction K-way merge (lab06) |
| WAL | 1 (append-only) | O(1) amortized | N/A | Sequential replay | Framed records (len+CRC) | Crash recovery (lab01) |
All six structures from labs 01β08 and the three comparison structures are tied
together in the capstone engine at lab08/db.go.
Summary: data structures touched by db.Put("name","Alice")
| Step | Data structure | Operation | Lab | Go source |
|---|---|---|---|---|
| 1 | Internal Key | Encode "name" || (seqNum<<8|TYPE_VALUE) | 02 | lab02/key.go |
| 2 | WAL | Append framed record + fdatasync | 01 | lab01/wal.go |
| 3 | Skip List | Put(internalKey, "Alice") in O(log n) | 02 | lab02/skiplist.go |
| 4 | MemTable | Thin wrapper; track size for flush trigger | 02 | lab02/memtable.go |
| 5 (flush) | SSTable Builder | Add per sorted key; varint encoding | 04 | lab04/sstable.go |
| 6 (compact) | Min-Heap | K-way merge of all L0+L1 iterators | 06 | lab06/iter.go |
| β | Red-Black Tree | Alternative in-memory MemTable index | β | β |
| β | B-Tree | SQLite page storage (option-*-sqlite labs) | β | option-a-sqlite/ |
| β | B+ Tree | SSTable β 1-level B+ tree; FDB Redwood | β | lab04/sstable.go |
All structures wired together in the capstone engine at lab08/db.go.
FDB Issue #4091 β Complete Contributor Guide
- Table of contents
- 1. Yes, FoundationDB is a C++ project β so why is the fix in Go?
- 2. FDB Architecture crash course
- 3. The three persistent options and what βpersistentβ means
- 4. The bug β in precise terms
- 5. Why would anyone use
Reset()instead ofOnError()? - 6. Our Go-layer fix β how it works
- 7. The deeper C++ fix (Phase 2)
- 8. Files in this workspace
- 9. How to submit this as a PR
- 10. Concepts you learn from this issue
- 11. Environment setup
- Quick-pick table
- Option A β macOS, bare metal
- Option B β Linux, bare metal
- Option C β Docker, single node
- Option D β Docker Compose, single node (this repo)
- Option E β 3-node cluster, bare metal (macOS / Linux)
- Option F β 3-node cluster, Docker Compose
- Confirming your setup before proceeding
- Clone the FDB repo (needed for PR work only)
- 12. Reproduce the bug
- 13. Apply the fix
- 14. Validate and verify
- 15. Pre-PR checklist
- 16. Building from source (optional β for the C++ phase)
- 17. Step-by-step PR guide
- 18. Understanding the FDB repository layout
- 19. Communication and community
- 20. Quick reference
Issue: Support persistent transaction options for custom retry loops
Label: good first issue
Opened: Nov 2020 Β· Still unassigned as of May 2026
How to use this document
If you are completely new to FDB, read Β§1β5 first (architecture, the bug).
To reproduce the bug, jump to Β§12.
To run and verify the fix, go to Β§13β14.
To submit the PR, follow Β§15β18.
Β§20 is a quick-reference command card.
Table of contents
- 1. Yes, FoundationDB is a C++ project β so why is the fix in Go?
- 2. FDB Architecture crash course
- 3. The three persistent options and what βpersistentβ means
- 4. The bug β in precise terms
- 5. Why would anyone use
Reset()instead ofOnError()? - 6. Our Go-layer fix β how it works
- 7. The deeper C++ fix (Phase 2)
- 8. Files in this workspace
- 9. How to submit this as a PR
- 10. Concepts you learn from this issue
- 11. Environment setup
- 12. Reproduce the bug
- 13. Apply the fix
- 14. Validate and verify
- 15. Pre-PR checklist
- 16. Building from source (optional β for the C++ phase)
- 17. Step-by-step PR guide
- 18. Understanding the FDB repository layout
- 19. Communication and community
- 20. Quick reference
1. Yes, FoundationDB is a C++ project β so why is the fix in Go?
FoundationDBβs core is C++. The storage engine, transaction coordinator, commit
protocol, fault-tolerance logic, the simulation framework β all of it lives in
C++ under fdbserver/, fdbclient/, and flow/.
But that C++ core exposes a thin C ABI (fdb_c.h / libfdb_c.dylib) that
every language binding uses as its foundation:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β FDB C++ Core β
β fdbserver fdbclient flow fdbrpc β
ββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β C ABI (libfdb_c.dylib)
β fdb_transaction_set_option()
β fdb_transaction_reset()
β fdb_transaction_on_error()
β ...
βββββββββββββββΌβββββββββββββββ
βΌ βΌ βΌ
Go bindings Python bindings Java bindings
(CGo wrapper) (ctypes) (JNI)
Issue #4091 lives at the boundary between the C ABI and the Go bindings. The issue was filed in the context of the Go API specifically β the words βcustom retry loopsβ and βsoft resetβ are Go-user problems β but the same gap exists in every language binding because they all call the same C functions.
The complete fix has two distinct layers
| Layer | Where | Difficulty | What it achieves |
|---|---|---|---|
| Go binding layer | bindings/go/src/fdb/ | Easy β pure Go | Prevents users from accidentally dropping options; makes intent explicit in code |
| C core layer | fdbclient/NativeAPI.actor.cpp | Harder β C++ Actor Framework | True preservation of accumulated elapsed time / retry count across Reset() |
This workspace implements the Go layer fix. The C layer fix is the next step once this is merged and the design is agreed upon with maintainers.
Starting with Go is correct for a first contribution because:
- You can test it without re-compiling the C++ cluster β use any running FDB
instance, including the pre-built package installed at
/usr/local. - Binding-layer changes are reviewed by a larger set of maintainers (they affect the developer API, not internal correctness).
- It unblocks users immediately while the deeper C change is designed.
2. FDB Architecture crash course
Before understanding the bug, you need to know how FDB handles transactions from a clientβs point of view.
2a. The FDB transaction lifecycle
db.CreateTransaction()
β
βΌ
βββββββββββββ
β PENDING β β reads, writes, Set options
βββββββ¬ββββββ
β tr.Commit()
βΌ
βββββββββββββ
β COMMITTED β β success, done
βββββββββββββ
or on conflict / retryable error:
β tr.OnError(err) OR tr.Reset()
βΌ
βββββββββββββ
β PENDING β β back to start, try again
βββββββββββββ
Two paths lead back to PENDING after a failure:
OnError(err)
- Calls
fdb_transaction_on_error()in the C layer. - The C layer decides if the error is retryable (e.g.
not_committed,future_version) and sleeps for a back-off interval. - The transactionβs read version, write set, and read/write conflict ranges are cleared β you get a fresh start on the next attempt.
- Critically: certain options are NOT cleared (see Β§3 below).
Reset()
- Calls
fdb_transaction_reset()in the C layer. - Equivalent to destroying the transaction object and creating a new one.
- All state is discarded β options, counters, everything.
2b. How the C layer actually stores transaction options
Inside NativeAPI.actor.cpp, every Transaction object holds a
TransactionOptions struct. When you call fdb_transaction_set_option(), the
C layer stores the value in that struct.
When fdb_transaction_on_error() is called, the implementation (simplified):
void Transaction::reset() {
// Copies the *persistent* options to a temporary, clears everything,
// then re-applies those options.
savedOptions = tr->options.persistentOptions(); // timeout, retry_limit, max_retry_delay
tr->fullReset();
tr->options.applyPersistent(savedOptions);
}
When fdb_transaction_reset() is called:
void Transaction::fullReset() {
// Clears absolutely everything, including options.
tr->options = TransactionOptions();
}
This is the asymmetry at the heart of the bug.
2c. The Actor Framework (Flow)
You will see .actor.cpp files throughout the FDB codebase. FDB uses its own
C++ coroutine system called Flow (predates C++20 coroutines). An ACTOR
is a function that can wait() on futures without blocking a thread:
ACTOR Future<Void> myActor(Database db) {
state Transaction tr(db);
loop {
try {
wait(tr.commit());
return Void();
} catch (Error& e) {
wait(tr.onError(e)); // back-off, then retry
}
}
}
When you see fdb_transaction_on_error() in the C header, it maps to a
generated wrapper around an Actor like this. The wait() call is what produces
the back-off delay (exponential back-off capped at max_retry_delay).
3. The three persistent options and what βpersistentβ means
Since FDB API version 610, three transaction options survive an OnError() call
without being re-applied by the caller:
SetTimeout(ms) β option code 500
Sets a wall-clock budget for the entire logical operation (all attempts
combined, not per attempt). The C layer records start_time when the option is
first set; each OnError() call checks whether now - start_time > timeout
and, if so, throws transaction_timed_out instead of retrying.
Because OnError does not clear this option, the clock keeps running across
retries. A 10-second timeout means the whole thing (first attempt + all retries)
must finish within 10 seconds.
SetRetryLimit(n) β option code 501
Sets a maximum number of calls to OnError(). The C layer keeps a counter that
increments every time OnError() is called and succeeds (i.e., the error was
retryable). When the counter reaches n, the next OnError() re-throws the
most-recently-seen error instead of sleeping and continuing.
Because OnError does not clear this option OR its counter, a retry limit of 5
means at most 5 retries total, not 5 per-attempt.
SetMaxRetryDelay(ms) β option code 502
Caps the exponential back-off sleep inside OnError(). Not as critical as the
other two, but itβs included in the persistence group for consistency.
4. The bug β in precise terms
Here is what goes wrong when you write a custom retry loop with Reset():
tr, _ := db.CreateTransaction()
for attempt := 0; ; attempt++ {
// We want: total timeout = 10 s, total retries β€ 5
// We get: timeout = 10 s PER ATTEMPT, retries = 5 PER ATTEMPT
tr.Options().SetTimeout(10_000)
tr.Options().SetRetryLimit(5)
doWork(tr)
err := tr.Commit().Get()
if err == nil { break }
// fdb_transaction_reset() β ALL state discarded
tr.Reset()
// The loop goes back to the top, re-sets the options (fresh counters),
// and the budget resets as if nothing happened.
}
On attempt 0: timeout clock starts, retry counter = 0.
Reset() is called. Clock and counter are gone.
On attempt 1: timeout clock starts fresh, retry counter = 0 again.
β¦
The user wrote SetRetryLimit(5) intending βgive up after 5 retries totalβ.
They actually got βretry up to 5 times per attempt, indefinitelyβ.
Why doesnβt the standard Transact helper have this problem?
// From bindings/go/src/fdb/database.go
func (d Database) Transact(f func(Transaction) (interface{}, error)) (interface{}, error) {
tr, _ := d.CreateTransaction()
for {
ret, e = f(tr)
if e == nil {
e = tr.Commit().Get()
}
if e == nil { return ret, nil }
// Uses OnError, NOT Reset.
// OnError preserves timeout + retry_limit in the C layer.
e = tr.OnError(ferr).Get()
if e != nil { return nil, e }
// Loop continues β same transaction object, counters intact.
}
}
Transact uses OnError which goes into the C layer, which specifically
carries the persistent options forward. Reset() bypasses that logic entirely.
5. Why would anyone use Reset() instead of OnError()?
OnError() is the right tool for standard retry loops. But there are real
scenarios where you want manual control:
Scenario A β Conditional retry logic
for {
result, err := doWork(tr)
if err != nil {
if isRetryable(err) && shouldRetry(result) {
tr.Reset() // custom decision, not delegated to FDB
continue
}
return err
}
...
}
Scenario B β Two-phase reads before a write
for {
snapshot := tr.Snapshot() // cheaper reads, no conflict tracking
data, _ := snapshot.GetRange(...).GetSliceWithError()
// Decide what to write based on what we read.
// If the computation is expensive, we may want to re-read from scratch
// rather than propagating a stale read version through OnError.
tr.Reset()
doWrite(tr, data)
err := tr.Commit().Get()
if err == nil { break }
...
}
Scenario C β Integration with external retry libraries
Some Go libraries (e.g. generic retry helpers) manage their own back-off and
just want to re-use the transaction object without going through FDBβs back-off
logic. They call Reset() and re-run the function.
In all these cases, the user loses their timeout / retry-limit semantics without any warning.
6. Our Go-layer fix β how it works
PersistentOptions β a value type
type PersistentOptions struct {
timeout *int64
retryLimit *int64
maxRetryDelay *int64
}
Using pointer fields (rather than, say, -1 as a sentinel) means you can
distinguish βnot setβ from βexplicitly set to 0β (which disables the feature).
The builder pattern (WithTimeout, WithRetryLimit, WithMaxRetryDelay)
returns a copy on each call, making PersistentOptions safe to pass by value
and share across goroutines.
SoftReset(tr, opts) β explicit intent
func SoftReset(tr fdb.Transaction, opts PersistentOptions) error {
tr.Reset() // calls fdb_transaction_reset() in C
return opts.Apply(tr) // calls fdb_transaction_set_option() for each set field
}
This does NOT solve the βaccumulated counterβ problem β after Reset(), the C
layer has no memory of elapsed time or previous retry count. Calling
SetTimeout(10_000) after Reset() starts a fresh 10-second clock.
What it does solve:
- Prevents accidentally forgetting to re-apply options.
- Makes the intent explicit and auditable in code review.
- A single call site (
SoftReset) is easier to audit than scatteredSetTimeout/SetRetryLimitcalls inside every retry loop.
TransactWithPersistentOptions(db, opts, f) β the proper helper
func TransactWithPersistentOptions(db fdb.Database, opts PersistentOptions,
f func(fdb.Transaction) (interface{}, error)) (interface{}, error) {
tr, _ := db.CreateTransaction()
opts.Apply(tr) // set options before first attempt
for {
ret, e = f(tr)
if e == nil { e = tr.Commit().Get() }
if e == nil { return ret, nil }
e = tr.OnError(ferr).Get() // β uses OnError, not Reset
if e != nil { return nil, e }
opts.Apply(tr) // re-apply after OnError (redundant for
// timeout/retry_limit at API >= 610, but
// correct for max_retry_delay and future
// options that may not yet be persistent)
}
}
This uses OnError internally, so it gets the true C-layer persistence for
free. The opts.Apply after OnError is technically redundant for timeout
and retry_limit (the C layer already preserved them), but it is harmless and
ensures the helper works correctly even if the user calls it at an older API
version.
7. The deeper C++ fix (Phase 2)
The Go helper SoftReset resets the elapsed-time and retry counters because it
calls fdb_transaction_reset() followed by fdb_transaction_set_option(). To
preserve those counters, we need a new C API function:
// Proposed addition to fdb_c.h
DLLEXPORT void fdb_transaction_soft_reset(FDBTransaction* tr);
And its implementation in fdbclient/NativeAPI.actor.cpp:
void Transaction::softReset() {
// Step 1: save the persistent options AND their accumulated state
auto saved = options.snapshotPersistentWithCounters();
// Step 2: full reset (clears read version, writes, conflicts, options)
fullReset();
// Step 3: restore the snapshot
options.restorePersistent(saved);
}
This would let the Go binding expose:
func (t Transaction) SoftReset() {
C.fdb_transaction_soft_reset(t.ptr)
}
And then SoftReset() would have the same semantics as OnError() for
option persistence β accumulated elapsed time and retry count continue across
the reset, exactly as users expect.
This is Phase 2 of the contribution:
- Open a PR with the Go binding layer changes from this workspace. β (this workspace)
- After that merges and the API is agreed, implement
fdb_transaction_soft_resetin C++ and update the Go binding to call it.
8. Files in this workspace
issue-4091-persistent-tx-options/
β
βββ go.mod Go module, depends on the FDB Go bindings
β
βββ fdb_retry/
β βββ retry.go The actual proposal β PersistentOptions,
β SoftReset(), TransactWithPersistentOptions().
β In a real PR, this code would live in
β bindings/go/src/fdb/persistent.go
β
βββ demo/
βββ main.go Runnable experiment. Shows three retry styles
side-by-side with a contention-driven counter
increment so you can observe the difference.
Running the demo
# Make sure FDB_CLUSTER_FILE is exported (see Β§11 for all setup options)
fdbcli -C "$FDB_CLUSTER_FILE" --exec "status minimal"
cd issue-4091-persistent-tx-options
go run ./demo
Expected output shape:
FDB issue #4091 β persistent transaction options demo
=====================================================
BROKEN (bare Reset, options silently dropped) elapsed=12ms counter=2
FIXED (SoftReset, options explicitly reapplied) elapsed=11ms counter=2
STANDARD (TransactWithPersistentOptions helper) elapsed=9ms counter=2
All three produce the correct counter value (2 β one increment per goroutine). The timing difference between BROKEN/FIXED and STANDARD becomes visible when you lower the timeout to force a timeout error β STANDARDβs budget is total across all attempts while BROKENβs resets per-attempt.
9. How to submit this as a PR
The FDB Go bindings live in a sub-module:
apple/foundationdb
βββ bindings/
βββ go/
βββ go.mod β separate Go module
βββ src/
βββ fdb/
βββ transaction.go
βββ database.go
βββ generated.go
βββ persistent.go β new file (our retry.go content)
Steps:
- Fork
apple/foundationdbon GitHub. - Clone your fork.
- Copy
fdb_retry/retry.gocontent intobindings/go/src/fdb/persistent.go, changepackage fdb_retrytopackage fdb, and remove thefdb.prefix from all FDB type references. - Add a test file
bindings/go/src/fdb/persistent_test.go. - Run the existing binding tests:
cd bindings/go && go test ./src/fdb/... - Open the PR, reference
#4091in the description, and note that Phase 2 (the C++fdb_transaction_soft_reset) is a follow-up.
10. Concepts you learn from this issue
| Concept | Where it appears |
|---|---|
| MVCC (multi-version concurrency control) | Why transactions conflict: two txns that read-then-write the same key get different read versions; if both commit, one loses and must retry |
| Optimistic concurrency | FDB never holds locks; conflicts are detected at commit time by comparing read/write conflict ranges |
| C ABI as a stability boundary | The C header is the stable surface; all language bindings sit above it so the C++ internals can change freely |
| Back-off and retry semantics | Why OnError sleeps before retrying; why the sleep duration is capped (max_retry_delay) |
| Builder pattern for immutable options | Why PersistentOptions returns copies instead of mutating in place |
| CGo interop | How Go calls into libfdb_c.dylib via import "C" and why every FDB Go file has // #include <foundationdb/fdb_c.h> |
11. Environment setup
Topology note for this issue: the bug lives entirely in the Go client library β
Reset()discards options before they reach the server. A single FDB process is sufficient to reproduce it, apply the fix, and run all tests. Contention is generated by concurrent goroutines in the demo, not by cluster topology. A 3-node cluster gives you more realistic conflict rates but is not required.
Quick-pick table
| Option | Platform | FDB install | Topology | Best for |
|---|---|---|---|---|
| A | macOS | Homebrew / pkg | Single node | Daily dev, fastest iteration |
| B | Linux | .deb / .rpm | Single node | CI machines, Linux dev |
| C | Any | Docker image | Single node | Isolated, no host install |
| D | Any | Docker Compose | Single node | Zero-config, uses repo files |
| E | macOS / Linux | Bare metal | 3-node cluster | Maximum conflict realism |
| F | Any | Docker Compose | 3-node cluster | CI-like, self-contained |
All options end with the same environment variable pointing to a cluster file, so every command in Β§12β15 works identically regardless of which you chose.
Option A β macOS, bare metal
Install FDB (skip if already installed):
# Verify existing install
fdbcli --version # FoundationDB CLI 7.x.x
ls /usr/local/lib/libfdb_c.dylib # CGo link target
If not installed, download the macOS package from
github.com/apple/foundationdb/releases
and run the .pkg installer. The installer places binaries in /usr/local/bin/
and the library in /usr/local/lib/.
Install Go 1.21+:
brew install go # or download from go.dev
go version # go version go1.21+
Start a single-node cluster:
mkdir -p ~/fdb-data ~/.fdb
# Generate a cluster file (description:randomID@host:port)
echo "local:$(od -An -tx8 -N8 /dev/urandom | tr -d ' \n')@127.0.0.1:4500" \
> ~/.fdb/fdb.cluster
fdbserver \
-p 127.0.0.1:4500 \
-C ~/.fdb/fdb.cluster \
-d ~/fdb-data \
-L /tmp/fdb-logs &
sleep 2
fdbcli -C ~/.fdb/fdb.cluster --exec "configure new single memory"
Export cluster file path (used by all subsequent steps):
export FDB_CLUSTER_FILE=~/.fdb/fdb.cluster
Verify:
fdbcli -C "$FDB_CLUSTER_FILE" --exec "status minimal"
# Output: The database is available.
Stop:
pkill fdbserver
Option B β Linux, bare metal
Install FDB:
# Debian / Ubuntu
FDB_VER=7.3.27
curl -LO "https://github.com/apple/foundationdb/releases/download/${FDB_VER}/foundationdb-clients_${FDB_VER}-1_amd64.deb"
curl -LO "https://github.com/apple/foundationdb/releases/download/${FDB_VER}/foundationdb-server_${FDB_VER}-1_amd64.deb"
sudo dpkg -i foundationdb-clients_*.deb foundationdb-server_*.deb
# RHEL / Fedora / Amazon Linux
FDB_VER=7.3.27
curl -LO "https://github.com/apple/foundationdb/releases/download/${FDB_VER}/foundationdb-clients-${FDB_VER}-1.el7.x86_64.rpm"
curl -LO "https://github.com/apple/foundationdb/releases/download/${FDB_VER}/foundationdb-server-${FDB_VER}-1.el7.x86_64.rpm"
sudo rpm -i foundationdb-clients-*.rpm foundationdb-server-*.rpm
The .deb/.rpm packages install and auto-start fdbserver via systemd, and
write a cluster file to /etc/foundationdb/fdb.cluster. Check status:
sudo systemctl status foundationdb
fdbcli --exec "status minimal" # uses /etc/foundationdb/fdb.cluster by default
Install Go 1.21+:
# Using the official tarball (adjust version as needed)
curl -LO https://go.dev/dl/go1.21.0.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
Export cluster file path:
export FDB_CLUSTER_FILE=/etc/foundationdb/fdb.cluster
Stop:
sudo systemctl stop foundationdb
Option C β Docker, single node
No local FDB install needed. Requires Docker Desktop (macOS/Windows) or
docker CLI (Linux).
# Pull and run a single FDB container
docker run -d \
--name fdb \
--network host \
-e FDB_NETWORKING_MODE=host \
-e FDB_COORDINATOR_PORT=4500 \
-e FDB_PORT=4500 \
-v "$PWD/fdb-data:/var/fdb/data" \
foundationdb/foundationdb:7.3.27
# Wait for it to start, then initialise the database
sleep 3
docker exec fdb fdbcli --exec "configure new single memory"
# Copy the cluster file out so the host Go process can use it
docker exec fdb cat /etc/foundationdb/fdb.cluster > ./fdb.cluster
macOS / Docker Desktop: Docker
--network hostdoes not work on macOS (host networking is Linux-only). Replace--network hostwith-p 4500:4500and update the cluster fileβs address to127.0.0.1:# macOS variant docker run -d --name fdb \ -p 4500:4500 \ -e FDB_NETWORKING_MODE=host \ -v "$PWD/fdb-data:/var/fdb/data" \ foundationdb/foundationdb:7.3.27 sleep 3 docker exec fdb fdbcli --exec "configure new single memory" docker exec fdb cat /etc/foundationdb/fdb.cluster \ | sed 's/@.*:/@127.0.0.1:/' > ./fdb.cluster
Export cluster file path:
export FDB_CLUSTER_FILE="$PWD/fdb.cluster"
Install the FDB client library on the host (CGo needs it to compile):
# macOS
brew install foundationdb # or use the .pkg from the releases page
# Linux β client-only package (no server binary needed)
sudo dpkg -i foundationdb-clients_7.3.27-1_amd64.deb
Verify:
fdbcli -C "$FDB_CLUSTER_FILE" --exec "status minimal"
Stop:
docker stop fdb && docker rm fdb
Option D β Docker Compose, single node (this repo)
This is the fastest zero-config path. The repo already has a
docker-compose.yml and a bootstrap script.
# From the repo root
docker compose up -d
bash scripts/bootstrap-fdb.sh # waits for healthy, writes ./fdb.cluster
export FDB_CLUSTER_FILE="$PWD/fdb.cluster"
Verify:
docker exec fdb-layers fdbcli --exec "status minimal"
# The database is available.
Stop:
docker compose down
Option E β 3-node cluster, bare metal (macOS / Linux)
Run three fdbserver processes on the same machine, each on a different port.
Use a shared cluster file that lists the coordinator.
mkdir -p ~/fdb-data/{n1,n2,n3} ~/.fdb /tmp/fdb-logs
# Write a cluster file pointing to node 1 as coordinator
echo "dev:$(od -An -tx8 -N8 /dev/urandom | tr -d ' \n')@127.0.0.1:4500" \
> ~/.fdb/fdb.cluster
CFILE=~/.fdb/fdb.cluster
# Start three server processes
fdbserver -p 127.0.0.1:4500 -C "$CFILE" -d ~/fdb-data/n1 -L /tmp/fdb-logs &
fdbserver -p 127.0.0.1:4501 -C "$CFILE" -d ~/fdb-data/n2 -L /tmp/fdb-logs &
fdbserver -p 127.0.0.1:4502 -C "$CFILE" -d ~/fdb-data/n3 -L /tmp/fdb-logs &
sleep 3
# Configure as a triple-redundancy cluster (requires β₯ 3 processes)
fdbcli -C "$CFILE" --exec "configure new triple memory"
# Verify all three processes are visible
fdbcli -C "$CFILE" --exec "status"
# Look for: FoundationDB processes - 3
Export cluster file path:
export FDB_CLUSTER_FILE=~/.fdb/fdb.cluster
Stop:
pkill fdbserver
Option F β 3-node cluster, Docker Compose
Save this as docker-compose-cluster.yml in the repo root:
services:
fdb1:
image: foundationdb/foundationdb:7.3.27
container_name: fdb-node1
hostname: fdb1
environment:
FDB_NETWORKING_MODE: container
FDB_COORDINATOR: fdb1
FDB_COORDINATOR_PORT: "4500"
FDB_PORT: "4500"
ports:
- "4500:4500"
volumes:
- ./fdb-data/n1:/var/fdb/data
- ./fdb-config:/etc/foundationdb
fdb2:
image: foundationdb/foundationdb:7.3.27
container_name: fdb-node2
hostname: fdb2
environment:
FDB_NETWORKING_MODE: container
FDB_COORDINATOR: fdb1
FDB_COORDINATOR_PORT: "4500"
FDB_PORT: "4500"
volumes:
- ./fdb-data/n2:/var/fdb/data
- ./fdb-config:/etc/foundationdb
depends_on: [fdb1]
fdb3:
image: foundationdb/foundationdb:7.3.27
container_name: fdb-node3
hostname: fdb3
environment:
FDB_NETWORKING_MODE: container
FDB_COORDINATOR: fdb1
FDB_COORDINATOR_PORT: "4500"
FDB_PORT: "4500"
volumes:
- ./fdb-data/n3:/var/fdb/data
- ./fdb-config:/etc/foundationdb
depends_on: [fdb1]
mkdir -p fdb-data/{n1,n2,n3} fdb-config
docker compose -f docker-compose-cluster.yml up -d
sleep 5
# Init the database as a triple-redundancy cluster
docker exec fdb-node1 fdbcli --exec "configure new triple memory"
# Copy cluster file to the host (coordinator address is fdb1:4500 inside the
# Docker network; expose it via the mapped port 4500 on 127.0.0.1)
docker exec fdb-node1 cat /etc/foundationdb/fdb.cluster \
| sed 's/fdb1/127.0.0.1/' > ./fdb.cluster
export FDB_CLUSTER_FILE="$PWD/fdb.cluster"
Verify:
fdbcli -C "$FDB_CLUSTER_FILE" --exec "status"
# FoundationDB processes - 3
# Database - available
Stop:
docker compose -f docker-compose-cluster.yml down
Confirming your setup before proceeding
Regardless of which option you used, run this check β every step in Β§12β15 depends on it passing:
# 1. Cluster is reachable
fdbcli -C "$FDB_CLUSTER_FILE" --exec "status minimal"
# Expected: The database is available.
# 2. Go toolchain works
go version
# Expected: go version go1.21+
# 3. CGo can link against libfdb_c
cd issue-4091-persistent-tx-options
go build ./fdb_retry/
# Expected: no output (success)
Clone the FDB repo (needed for PR work only)
The steps below do not require this. Clone only when you are ready for Β§15.
# Fork apple/foundationdb on GitHub first, then:
git clone git@github.com:YOUR_USERNAME/foundationdb.git ~/src/foundationdb
cd ~/src/foundationdb
git remote add upstream https://github.com/apple/foundationdb.git
git fetch upstream
cd bindings/go && go mod download
12. Reproduce the bug
These commands work with any option from Β§11. FDB_CLUSTER_FILE must be
exported (or ~/.fdb/fdb.cluster / /etc/foundationdb/fdb.cluster must
exist as the default).
12a. Minimal standalone repro
No workspace clone needed β paste and run in any temporary directory.
mkdir /tmp/fdb-repro && cd /tmp/fdb-repro
cat > go.mod << 'EOF'
module repro
go 1.21
require github.com/apple/foundationdb/bindings/go v0.0.0-20231107151356-57ccdb8fee6d
EOF
go mod download
cat > main.go << 'EOF'
package main
import (
"fmt"
"time"
"github.com/apple/foundationdb/bindings/go/src/fdb"
)
func main() {
fdb.MustAPIVersion(730)
db, _ := fdb.OpenDefault()
key := fdb.Key("repro:counter")
// --- BROKEN: Reset() silently drops the retry_limit counter ---
tr, _ := db.CreateTransaction()
attempts := 0
start := time.Now()
for {
attempts++
tr.Options().SetRetryLimit(3) // intent: stop after 3 retries total
tr.Get(key)
tr.Set(key, []byte{byte(attempts)})
err := tr.Commit().Get()
if err == nil { break }
if attempts > 20 {
fmt.Printf("BROKEN: gave up manually after %d attempts (%v) β SetRetryLimit ignored\n",
attempts, time.Since(start))
break
}
tr.Reset() // nukes the retry_limit counter; loop re-sets it to 0
}
// --- CORRECT: OnError preserves the counter in the C layer ---
tr2, _ := db.CreateTransaction()
tr2.Options().SetRetryLimit(3)
attempts2 := 0
start2 := time.Now()
for {
attempts2++
tr2.Get(key)
tr2.Set(key, []byte{byte(attempts2)})
err := tr2.Commit().Get()
if err == nil {
fmt.Printf("CORRECT: committed after %d attempt(s) (%v)\n",
attempts2, time.Since(start2))
break
}
ferr, _ := err.(fdb.Error)
if retryErr := tr2.OnError(ferr).Get(); retryErr != nil {
fmt.Printf("CORRECT: stopped after %d attempt(s) as intended (%v): %v\n",
attempts2, time.Since(start2), retryErr)
break
}
}
}
EOF
go run .
Expected output:
BROKEN: gave up manually after 21 attempts (38ms) β SetRetryLimit ignored
CORRECT: stopped after 4 attempt(s) as intended (5ms): transaction_too_old
The BROKEN path hits the safety valve (21 attempts) because every Reset()
resets the retry counter to zero. The CORRECT path stops at 4 attempts
(1 initial + 3 retries) as SetRetryLimit(3) intended.
Why
transaction_too_old? In a low-contention single-node cluster, the first commit usually succeeds. Forced retries come from the read version expiring while the loop runs, not from write conflicts. The full workspace demo (Β§12b) generates real write conflicts using concurrent goroutines.
12b. Full workspace demo
cd issue-4091-persistent-tx-options
# Confirm FDB is reachable
fdbcli -C "$FDB_CLUSTER_FILE" --exec "status minimal"
# Run the three-way comparison
go run ./demo
The demo spawns two goroutines per experiment, both writing to the same key, so they generate real write conflicts. Expected output:
FDB issue #4091 β persistent transaction options demo
=====================================================
BROKEN (bare Reset, options silently dropped) elapsed=14ms counter=2
FIXED (SoftReset, options explicitly reapplied) elapsed=12ms counter=2
STANDARD (TransactWithPersistentOptions helper) elapsed=10ms counter=2
All three show counter=2 β both goroutines incremented exactly once (correct
final value). The elapsed times look similar at default settings.
To make the semantic difference obvious, edit demo/main.go and lower the
timeout to force a timeout error on the BROKEN path:
# In demo/main.go, set: const timeoutMs = 50
go run ./demo
BROKEN ... elapsed=312ms counter=2 β budget kept resetting; ran 300+ ms
STANDARD ... elapsed=52ms counter=2 β hard-stopped at 50 ms total budget
With a 3-node cluster (Options E or F): the demo works identically. The
cluster topology does not affect the result β the conflict is goroutine-driven,
not cluster-driven. The 3-node setup is useful if you want to observe real
not_committed conflict errors rather than transaction_too_old expiry errors,
which you can do by monitoring fdbcli --exec "status json" while the demo runs.
13. Apply the fix
The fix is already implemented in fdb_retry/retry.go. This section shows
how to verify it compiles, run it, and understand what it changes.
13a. Verify the fix compiles
cd issue-4091-persistent-tx-options
go build ./...
go vet ./...
No output means success.
13b. Run with the fix active
The FIXED row in the demo output is the SoftReset path. The STANDARD
row is TransactWithPersistentOptions. Both use the code in fdb_retry/retry.go.
go run ./demo
To confirm the fix is being used, check fdb_retry/retry.go:
grep -n "func SoftReset\|func TransactWith" fdb_retry/retry.go
# fdb_retry/retry.go:NN: func SoftReset(tr fdb.Transaction, opts PersistentOptions) error {
# fdb_retry/retry.go:NN: func TransactWithPersistentOptions(...) (interface{}, error) {
13c. What the fix does (summary)
| API | What it does | Counters reset? |
|---|---|---|
tr.Reset() (buggy path) | Clears everything including options | Yes β clock and retry count start over |
SoftReset(tr, opts) | Reset() then re-applies options | Yes β but re-application is explicit and auditable |
TransactWithPersistentOptions | Uses OnError internally | No β C layer preserves elapsed time and retry count |
SoftReset is a pragmatic fix for code that genuinely needs Reset(). The
fully correct solution for preserving counters is TransactWithPersistentOptions
(which uses OnError), or the Phase 2 C++ change described in Β§7.
14. Validate and verify
14a. Unit tests β no FDB cluster needed
These test the PersistentOptions builder logic: value semantics, no mutation,
correct field access. They run anywhere, no server required.
cd issue-4091-persistent-tx-options
go test ./fdb_retry/... -v -count=1
Expected:
=== RUN TestPersistentOptionsBuilder
--- PASS: TestPersistentOptionsBuilder (0.00s)
PASS
ok github.com/10xdev/fdb-issue-4091/fdb_retry
If the test file does not exist yet, create fdb_retry/retry_test.go:
package fdb_retry_test
import (
"testing"
fdb_retry "github.com/10xdev/fdb-issue-4091/fdb_retry"
)
func TestPersistentOptionsBuilder(t *testing.T) {
var opts fdb_retry.PersistentOptions
if opts.HasTimeout() {
t.Error("zero value should have no timeout")
}
opts = opts.WithTimeout(5000)
if !opts.HasTimeout() || opts.TimeoutMs() != 5000 {
t.Errorf("WithTimeout: HasTimeout=%v TimeoutMs=%d", opts.HasTimeout(), opts.TimeoutMs())
}
var zero fdb_retry.PersistentOptions
if zero.HasTimeout() {
t.Error("original was mutated")
}
opts3 := fdb_retry.PersistentOptions{}.
WithTimeout(10_000).WithRetryLimit(5).WithMaxRetryDelay(500)
if !opts3.HasTimeout() || !opts3.HasRetryLimit() || !opts3.HasMaxRetryDelay() {
t.Error("chained builder missing a field")
}
}
HasTimeout(),TimeoutMs(), etc. must be exported methods onPersistentOptionsfor this test. Add them when writing the PR version ofpersistent.go. The integration tests below exercise the full path through the private fields.
14b. Integration tests β FDB cluster required
These test the runtime behavior: retry limits are enforced, SoftReset
re-applies options, write sets are cleared.
First, confirm your cluster is reachable:
fdbcli -C "$FDB_CLUSTER_FILE" --exec "status minimal"
Create fdb_retry/integration_test.go:
//go:build integration
package fdb_retry_test
import (
"testing"
fdb_retry "github.com/10xdev/fdb-issue-4091/fdb_retry"
"github.com/apple/foundationdb/bindings/go/src/fdb"
)
func setupDB(t *testing.T) fdb.Database {
t.Helper()
fdb.MustAPIVersion(730)
db, err := fdb.OpenDefault()
if err != nil {
t.Skipf("FDB not available: %v", err)
}
return db
}
// TestRetryLimitIsRespected proves the helper stops after the configured limit.
func TestRetryLimitIsRespected(t *testing.T) {
db := setupDB(t)
attempts := 0
opts := fdb_retry.PersistentOptions{}.WithRetryLimit(2)
_, err := fdb_retry.TransactWithPersistentOptions(db, opts,
func(tr fdb.Transaction) (interface{}, error) {
attempts++
tr.Get(fdb.Key("test:retry_limit"))
tr.Set(fdb.Key("test:retry_limit"), []byte("x"))
return nil, fdb.Error{Code: 1020} // not_committed β always retryable
},
)
if err == nil {
t.Fatal("expected error after retry limit")
}
// RetryLimit(2) β 1 initial + 2 retries = 3 total attempts maximum
if attempts > 3 {
t.Errorf("retry limit not respected: %d attempts, want β€ 3", attempts)
}
t.Logf("stopped after %d attempts: %v", attempts, err)
}
// TestSoftResetClearsWriteSetButKeepsOptions proves write set is gone after
// SoftReset but the re-applied options allow a successful commit.
func TestSoftResetClearsWriteSetButKeepsOptions(t *testing.T) {
db := setupDB(t)
key := fdb.Key("test:soft_reset")
opts := fdb_retry.PersistentOptions{}.WithTimeout(30_000).WithRetryLimit(10)
tr, _ := db.CreateTransaction()
opts.Apply(tr)
tr.Set(key, []byte("before"))
if err := fdb_retry.SoftReset(tr, opts); err != nil {
t.Fatalf("SoftReset: %v", err)
}
// "before" write is gone; options are active; new write should commit
tr.Set(key, []byte("after"))
if err := tr.Commit().Get(); err != nil {
t.Fatalf("Commit after SoftReset: %v", err)
}
val, _ := db.Transact(func(tr2 fdb.Transaction) (interface{}, error) {
return tr2.Get(key).Get()
})
if string(val.([]byte)) != "after" {
t.Errorf("expected 'after', got %q β Reset did not clear write set", val)
}
}
Run:
# Single node or 3-node β same command
go test ./fdb_retry/... -tags integration -v -count=1
Expected:
=== RUN TestRetryLimitIsRespected
retry_test.go:NN: stopped after 3 attempts: transaction_too_old
--- PASS: TestRetryLimitIsRespected (0.12s)
=== RUN TestSoftResetClearsWriteSetButKeepsOptions
--- PASS: TestSoftResetClearsWriteSetButKeepsOptions (0.03s)
PASS
14c. Behavioral verification with fdbcli
After running the full demo, check the final key value:
fdbcli -C "$FDB_CLUSTER_FILE" --exec "get issue4091:counter"
# `issue4091:counter' is `\x00\x00\x00\x00\x00\x00\x00\x02'
\x02 as a big-endian int64 means 2 β both goroutines incremented exactly once.
Additional spot-checks:
| What to confirm | Command | Pass condition |
|---|---|---|
| No orphaned data | fdbcli --exec "getrange \x00 \xff" | Only issue4091:* and test:* keys |
| Demo exits cleanly | time go run ./demo | Exits in < 5 s |
| RetryLimit(3) fires | Run minimal repro (Β§12a) | CORRECT path prints βstopped after 4 attempt(s)β |
Clean up test keys before opening the PR:
fdbcli -C "$FDB_CLUSTER_FILE" \
--exec "writemode on; clearrange issue4091 issue4092; clearrange test: test;"
14d. Run with the race detector
FDB operations are concurrent. Run with -race before opening the PR:
go test ./fdb_retry/... -tags integration -race -count=1
go run -race ./demo
Both should complete with no DATA RACE warnings.
15. Pre-PR checklist
Work through this in order. Every item must be green before opening the PR.
SETUP (Β§11)
[ ] fdbcli status minimal β "The database is available."
[ ] go build ./... β no errors
[ ] go vet ./... β no warnings
REPRODUCE THE BUG (Β§12)
[ ] Minimal repro (Β§12a): BROKEN path hits safety valve (21 attempts)
[ ] Full demo (Β§12b): BROKEN row shows inflated elapsed time with
timeoutMs = 50
APPLY THE FIX (Β§13)
[ ] go build ./... β no errors after any edits to retry.go
[ ] Full demo: FIXED and STANDARD rows show correct counter=2
and elapsed time β€ timeoutMs + small overhead
VALIDATE (Β§14)
[ ] Unit tests go test ./fdb_retry/... -v -count=1
[ ] Integration tests go test ./fdb_retry/... -tags integration -v -count=1
[ ] Race detector go test ./fdb_retry/... -tags integration -race -count=1
[ ] fdbcli key check issue4091:counter = \x02
[ ] Test data cleaned clearrange wiped issue4091:* and test:* keys
PR PREPARATION (Β§16)
[ ] Copy retry.go β bindings/go/src/fdb/persistent.go
[ ] Change package fdb_retry β package fdb
[ ] Remove fdb. prefix from all FDB type references
[ ] Make TransactWithPersistentOptions a method on Database
[ ] cd bindings/go && go test ./src/fdb/... -race -timeout 120s β all pass
[ ] Stack tester still passes (Β§16 step 5)
[ ] Commit message references #4091 (Β§16 step 6)
16. Building from source (optional β for the C++ phase)
You do not need to build FDB from source for the Go layer fix. The
pre-installed /usr/local/lib/libfdb_c.dylib is sufficient. This section
documents the build process for when you later implement the C++ phase.
16a. Prerequisites
# Xcode command-line tools
xcode-select --install
# CMake 3.24+
brew install cmake
# Ninja (faster builds than make)
brew install ninja
# Mono (required for the flow code generator)
brew install mono
# Boost (FDB uses specific Boost headers)
brew install boost
# Go 1.21+ (already installed if you are reading this)
16b. Configure and build
cd ~/src/foundationdb
# Create a build directory (out-of-source build)
cmake -G Ninja -S . -B build \
-DCMAKE_BUILD_TYPE=Debug \
-DFDB_RELEASE=OFF \
-DOPEN_FOR_IDE=OFF
# Build only what you need for C++ work.
# fdbserver + fdbcli takes ~20 min on M1; libfdb_c takes ~5 min.
cd build
ninja fdbserver fdbcli fdb_c # or just: ninja (builds everything ~40 min)
Build artifacts land in build/:
build/bin/fdbserver
build/bin/fdbcli
build/lib/libfdb_c.dylib
To test your custom-built binaries instead of the system ones:
# Point the Go binding to your local libfdb_c
export CGO_CFLAGS="-I$HOME/src/foundationdb/fdbrpc/include"
export CGO_LDFLAGS="-L$HOME/src/foundationdb/build/lib"
export DYLD_LIBRARY_PATH="$HOME/src/foundationdb/build/lib:$DYLD_LIBRARY_PATH"
# Run the demo against your custom-built server
~/src/foundationdb/build/bin/fdbserver \
-p 127.0.0.1:4501 \
-C ~/.fdb/fdb.cluster \
-d ~/fdb-data2 &
go run ./demo
16c. Running FDBβs own test suite (simulation)
FDBβs killer feature is its deterministic simulation framework. Bugs that take months to appear in production can be reproduced in seconds under simulation. This is what you run for C++ changes:
cd build
# Joshua is FDB's simulation runner β this runs 100 simulation seeds:
bin/fdbserver -r simulation -f tests/fast/WriteTagThrottling.toml -s 0
# -s <seed> sets the random seed; different seeds exercise different paths
# Or use the CMake test target (runs the full suite, takes a long time):
ctest -j4 --output-on-failure
You do not need to touch simulation for the Go binding change (Phase 1).
Simulation is relevant for Phase 2 (the C++ fdb_transaction_soft_reset).
17. Step-by-step PR guide
Step 1 β Fork and branch
# On GitHub: click "Fork" on https://github.com/apple/foundationdb
# Then locally:
cd ~/src/foundationdb
git checkout -b fix/go-persistent-tx-options main
Step 2 β Place the new file
# The Go bindings sub-module
cd bindings/go/src/fdb
Create persistent.go. The content is the same as fdb_retry/retry.go in
this workspace, with two changes:
- Change
package fdb_retryβpackage fdb - Remove the
fdb.qualifier from all FDB type references (they are now in the same package). For example:fdb.TransactionβTransactionfdb.DatabaseβDatabasefdb.ErrorβError
The function signatures become:
// In package fdb β no fdb. prefix needed
func (o PersistentOptions) Apply(tr Transaction) error { ... }
func SoftReset(tr Transaction, opts PersistentOptions) error { ... }
func (d Database) TransactWithPersistentOptions(
opts PersistentOptions,
f func(Transaction) (interface{}, error),
) (interface{}, error) { ... }
Note that TransactWithPersistentOptions becomes a method on Database
(consistent with Transact and ReadTransact).
Step 3 β Add tests
Create bindings/go/src/fdb/persistent_test.go with the integration tests
from Β§13c, adapted to the fdb package:
package fdb_test // external test package, consistent with existing tests
import (
"testing"
"github.com/apple/foundationdb/bindings/go/src/fdb"
)
Check how the existing test file fdb_test.go sets up its cluster connection
and mirror that pattern exactly.
Step 4 β Run existing tests
cd bindings/go
# Run all Go binding tests (requires fdbserver running)
go test ./src/fdb/... -v -timeout 120s
# Run with the race detector (FDB's CI always does this)
go test ./src/fdb/... -race -timeout 120s
All pre-existing tests must still pass. If any fail, something in your new file has a name collision or an import cycle.
Step 5 β Run the stack tester
FDBβs binding correctness is verified by a deterministic βstack testerβ that
replays a sequence of API calls and compares output across all language
bindings. It lives at bindings/go/src/_stacktester/.
# Build the stack tester binary
cd bindings/go
go build -o /tmp/go_stacktester ./src/_stacktester/
# Run it against a live cluster (the Python reference binding runs in parallel)
/tmp/go_stacktester --cluster-file ~/.fdb/fdb.cluster --test-name api --num-ops 1000
Your new PersistentOptions and SoftReset APIs are not exercised by the
stack tester (it only tests core read/write/commit operations), but you must
confirm the stack tester still passes to show no regressions.
Step 6 β Commit style
FDB uses a conventional commit format with a component prefix:
bindings/go: add PersistentOptions and SoftReset for custom retry loops
Closes #4091
Transaction options timeout, retry_limit, and max_retry_delay are
preserved by the C layer across OnError() calls (API >= 610), but are
silently discarded when a custom retry loop calls Reset() instead.
This adds:
- PersistentOptions: a builder-style value type for the three options.
- PersistentOptions.Apply(tr): sets all stored options on a transaction.
- SoftReset(tr, opts): calls Reset() then Apply(), making the option
reapplication explicit and auditable.
- Database.TransactWithPersistentOptions(opts, f): a drop-in for
Database.Transact that uses OnError internally (so the C layer's own
persistence is honoured) and also reapplies opts after each retry.
A deeper fix (fdb_transaction_soft_reset in NativeAPI.actor.cpp) that
preserves accumulated elapsed-time and retry counters across Reset() is
tracked as a follow-up to this PR.
Step 7 β Open the PR
- Push the branch:
git push origin fix/go-persistent-tx-options - Open a PR against
apple/foundationdb:mainon GitHub. - In the PR description:
- Link the issue:
Closes #4091 - Explain what Phase 2 would look like (the C++ change)
- Mention which tests you ran and on which FDB version
- Link the issue:
- Request review from
@sfc-gh-abeamon(who filed the issue) or@jzhou77(who labelled it).
Step 8 β Respond to review
FDB maintainers are thorough. Common review points for binding PRs:
- API naming consistency (match the style of
Transact,ReadTransact) - Whether
TransactWithPersistentOptionsshould be a free function or a method onDatabase - Whether
SoftResetneeds to be exported vs. internal - Test coverage for the zero-value case (no options set)
18. Understanding the FDB repository layout
When you open the repo for the first time, the directory count is overwhelming. Here is a map of the directories you actually touch for this issue and related work:
foundationdb/
β
βββ bindings/ β language bindings
β βββ go/ β Go sub-module (separate go.mod)
β β βββ src/fdb/
β β βββ transaction.go β Transaction type, Cancel/Reset/OnError/Commit
β β βββ database.go β Database type, Transact/ReadTransact
β β βββ generated.go β SetTimeout/SetRetryLimit etc (auto-generated)
β β βββ persistent.go β β β YOUR NEW FILE
β βββ python/ β Python binding (same gap exists here)
β βββ java/ β Java binding
β
βββ fdbclient/
β βββ NativeAPI.actor.cpp β C++ Transaction implementation
β β fdb_transaction_reset() lives here
β β This is where Phase 2 changes go
β βββ vexillographer/
β β βββ fdb.options β Source of truth for all option codes
β β Option 500=timeout, 501=retry_limit, 502=max_retry_delay
β β generated.go is built FROM this file
β βββ include/foundationdb/
β βββ fdb_c.h β C ABI header; all bindings #include this
β
βββ fdbserver/
β βββ workloads/ β simulation workloads (C++ integration tests)
β
βββ flow/
β βββ actorcompiler/ β transforms ACTOR keyword into plain C++
β βββ flow.h β Future<T>, ACTOR macros
β
βββ tests/
βββ rare/ β simulation test scenarios
The generated.go pipeline
generated.go is not hand-written. It is produced by a script that reads
fdbclient/vexillographer/fdb.options. If you ever need to add a new option
you would edit fdb.options, run the generator, and commit the output. For
this issue we only add Go types and helpers on top of existing option codes, so
there is no need to re-run the generator.
# How to regenerate bindings/go/src/fdb/generated.go (for reference only)
cd bindings/go/src/_util
go run translate_fdb_options.go \
../../../../fdbclient/vexillographer/fdb.options \
> ../fdb/generated.go
Actor Framework (Flow) quick reference
You will see this pattern throughout fdbclient/:
ACTOR Future<Void> someOperation(Database db) {
state Transaction tr(db);
loop {
try {
wait(tr.someAsyncOp());
return Void();
} catch (Error& e) {
wait(tr.onError(e));
}
}
}
Key terms:
| Term | Meaning |
|---|---|
ACTOR | Function that can suspend with wait(). Compiled by actorcompiler into a state machine. |
state | Variable that persists across wait() points (like a field in a coroutine frame). |
wait(f) | Suspends until future f resolves. Does NOT block the thread. |
Future<T> | Like std::future<T> but non-blocking and composable. |
loop { } | Equivalent to for(;;) but signals to the compiler that this is an intentional infinite loop inside an Actor. |
19. Communication and community
Before opening a PR, comment on issue #4091 to claim it:
βIβd like to work on this. My approach: add
PersistentOptions,SoftReset, andDatabase.TransactWithPersistentOptionsto the Go bindings as Phase 1, with the C++fdb_transaction_soft_resetas a follow-up PR.β
Channels:
- GitHub Issues / PR comments β primary channel for technical discussion
- FDB Community Forums β higher-level questions
- Discord β real-time chat with maintainers
What maintainers care about:
- Correctness β the fix must not introduce a regression in any binding
- Consistency β match the naming and style of existing
Transact/ReadTransact - Test coverage β both unit and integration tests
- Documentation β update
doc.goor add godoc to new exported symbols - Backward compatibility β the change must be additive (no existing signatures change)
20. Quick reference
FDB cluster management
# ββ Option A/B: bare metal βββββββββββββββββββββββββββββββββββββββββββββββββ
export FDB_CLUSTER_FILE=~/.fdb/fdb.cluster # macOS
export FDB_CLUSTER_FILE=/etc/foundationdb/fdb.cluster # Linux systemd install
fdbcli -C "$FDB_CLUSTER_FILE" --exec "status minimal" # health check
pkill fdbserver # stop all processes
# ββ Option C: Docker single node βββββββββββββββββββββββββββββββββββββββββββ
docker run -d --name fdb -p 4500:4500 \
foundationdb/foundationdb:7.3.27
docker exec fdb fdbcli --exec "configure new single memory"
docker exec fdb cat /etc/foundationdb/fdb.cluster \
| sed 's/@.*:/@127.0.0.1:/' > ./fdb.cluster
export FDB_CLUSTER_FILE="$PWD/fdb.cluster"
docker stop fdb && docker rm fdb # stop
# ββ Option D: Docker Compose single node (this repo) βββββββββββββββββββββββ
docker compose up -d && bash scripts/bootstrap-fdb.sh
export FDB_CLUSTER_FILE="$PWD/fdb.cluster"
docker compose down # stop
# ββ Option E: 3-node bare metal ββββββββββββββββββββββββββββββββββββββββββββ
fdbserver -p 127.0.0.1:4500 -C ~/.fdb/fdb.cluster -d ~/fdb-data/n1 &
fdbserver -p 127.0.0.1:4501 -C ~/.fdb/fdb.cluster -d ~/fdb-data/n2 &
fdbserver -p 127.0.0.1:4502 -C ~/.fdb/fdb.cluster -d ~/fdb-data/n3 &
sleep 3 && fdbcli -C ~/.fdb/fdb.cluster --exec "configure new triple memory"
pkill fdbserver # stop
# ββ Option F: 3-node Docker Compose ββββββββββββββββββββββββββββββββββββββββ
docker compose -f docker-compose-cluster.yml up -d
sleep 5
docker exec fdb-node1 fdbcli --exec "configure new triple memory"
docker exec fdb-node1 cat /etc/foundationdb/fdb.cluster \
| sed 's/fdb1/127.0.0.1/' > ./fdb.cluster
export FDB_CLUSTER_FILE="$PWD/fdb.cluster"
docker compose -f docker-compose-cluster.yml down # stop
Reproduce β apply fix β validate β PR
# ββ REPRODUCE THE BUG ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
cd /tmp/fdb-repro && go run . # minimal standalone (Β§12a)
# expect: BROKEN hits 21 attempts
cd issue-4091-persistent-tx-options
go run ./demo # full three-way comparison (Β§12b)
# expect: BROKEN/FIXED/STANDARD all counter=2
# Edit demo/main.go: set timeoutMs = 50, then re-run to see elapsed diverge
# ββ APPLY THE FIX ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
go build ./... # must compile cleanly
go vet ./... # must pass
# ββ VALIDATE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
go test ./fdb_retry/... -v -count=1 # unit tests, no FDB
go test ./fdb_retry/... -tags integration -v -count=1 # integration, FDB needed
go test ./fdb_retry/... -tags integration -race -count=1 # race detector
go run -race ./demo # race detector on demo
# Spot check final key value
fdbcli -C "$FDB_CLUSTER_FILE" --exec "get issue4091:counter"
# β `issue4091:counter' is `\x00\x00\x00\x00\x00\x00\x00\x02'
# Clean up test keys
fdbcli -C "$FDB_CLUSTER_FILE" \
--exec "writemode on; clearrange issue4091 issue4092; clearrange test: test;"
# ββ PR PREPARATION βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 1. cp fdb_retry/retry.go β bindings/go/src/fdb/persistent.go
# 2. Change package + remove fdb. prefixes + make TransactWith... a method
# 3. cd bindings/go && go test ./src/fdb/... -race -timeout 120s
# 4. Run stack tester (Β§16 step 5)
# 5. git push && open PR referencing #4091