Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

πŸ“– Local build β€” commit and timestamp are injected automatically on Cloudflare Pages.

Preface β€” Why This Book Exists

β€œ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 fsync calls 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:

  1. Explain to a colleague exactly what happens during db.Transact(...) β€” every network hop, every disk write, every B-tree page touched.
  2. Read the FoundationDB source tree (fdbserver/, fdbclient/, fdbrpc/, flow/) and know which files to open for a given question.
  3. Design a new layer (graph, vector index, time series, queue) from scratch, choosing the right key encoding, conflict-range strategy, and atomic op primitives.
  4. Reproduce the published benchmarks and reason about why your workload deviates from them.
  5. 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 β€” BackgroundWhy
1. The Storage StackThe vocabulary: B-tree vs LSM, WAL, buffer pool, RUM trade-off.
2. FoundationDB in DepthCluster roles, commit pipeline, MVCC, watches, atomic ops.
3. Storage Engines: SQLite β†’ Redwood β†’ Sharded RocksDBHow the on-disk format evolved and why. Source-level.
4. Flow, Actors, and SimulationThe C++ extension that makes FDB possible. Worked source examples.
5. Performance β€” Latency, Throughput, Concurrency in NumbersHard numbers, reproducible workloads, capacity planning.
6. The Layer ConceptKey encoding, subspaces, atomicity, conflict ranges.
7. How Real Systems Use FDBCloudKit, Snowflake, Wavefront, Document Layer, TiKV, mvsqlite.
8. This RepositoryMap of the labs and how to navigate them.
9. Reading GuidePapers, talks, books, and source paths to read next.
10. Contributing to FoundationDBBuilding the source, first-PR ideas, where the maintainers hang out.
Part II β€” LabsPlugs inTeaches
Option A β€” LevelDB API over FDBAbove LevelDBExternal KV API, iterators, snapshots, batches
Option A β€” SQL Layer over FDBAbove SQLiteHow SQL decomposes into storage ops
Option B β€” LevelDB on FDB StorageBelow LevelDBLevelDB internals: SSTs, WAL, MANIFEST
Option B β€” SQLite VFS on FDBBelow SQLiteSQLite internals: page model, VFS, journaling
Option C β€” Record Layer over FDBDirectly on FDBNative 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

FolderPlugs inTeaches
option-a-leveldbAbove LevelDBLevelDB’s external API: iterators, snapshots, write batches
option-a-sqliteAbove SQLiteHow SQL decomposes into storage ops; vtab query planning
option-b-leveldbBelow LevelDBLevelDB internals: SST files, WAL, MANIFEST, CURRENT
option-b-sqliteBelow SQLiteSQLite internals: page model, VFS, journaling
option-c-record-layerDirectly on FDBNative FDB layer: records + secondary indexes

Prerequisites

  1. Docker β€” to run a local single-node FDB cluster
  2. FoundationDB client library on the host (libfdb_c) β€” required by the Go bindings (CGO)
    • macOS: brew install foundationdb (or install the official .pkg from Apple’s release page)
  3. 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.

ChapterTopicWhat you’ll know after
1. The Storage StackB-trees, LSM-trees, WAL, buffer poolsWhy every database makes the same five trade-offs
2. FoundationDB in DepthMVCC, commit pipeline, cluster roles, watches, atomic opsHow FDB achieves correctness end-to-end
3. Storage EnginesSQLite β†’ Redwood β†’ Sharded RocksDB, source-levelWhat is actually written to disk, and why it changed
4. Flow, Actors, and SimulationThe C++ extension, the event loop, deterministic testingHow to read any .actor.cpp file in the source tree
5. PerformanceLatency, throughput, concurrency in concrete numbersHow to size and predict an FDB cluster’s behavior
6. The Layer ConceptKey encoding, subspace pattern, conflict rangesHow to turn an ordered KV store into any data model
7. The Record Layer β€” A Deep DiveApple’s open-source Record Layer, source-levelHow CloudKit-grade record storage is built on FDB
8. How Real Systems Use FDBCloudKit, mvsqlite, Document Layer, TiKV, SnowflakeThat the patterns in this repo are battle-tested in production
9. This RepositoryThe five lab implementationsHow to navigate, run, and extend the labs
10. Reading GuidePapers, books, source code to read nextWhere to go to become a true expert
11. Contributing to FoundationDBBuilding from source, first-PR catalogA 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

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.

EngineRead (R)Update (U)Memory (M)Best for
B-treeLowMediumMediumRead-heavy OLTP
LSM treeMediumLowHighWrite-heavy OLTP
Hash tableVery lowMediumHighPoint lookups only
Column storeLowHighLowAnalytical queries (OLAP)
FDB (B-tree)LowMediumMediumConsistent 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

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 on user: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:

  1. Reads bypass the Proxy β€” they go directly to Storage Servers. This means read throughput scales linearly with Storage Server count.
  2. Writes buffer locally β€” no network traffic until commit. A transaction that writes 1,000 keys generates exactly one network round-trip.
  3. 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 readVersion and commitVersion. No lock managers, no 2PL.
  4. Durability before acknowledgment β€” FDB doesn’t ack a commit until the write is in the Transaction Log on f+1 machines. 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:

  1. Read a batch of data, process it
  2. Commit the batch
  3. Read the next batch in a new transaction

MVCC vs. Locking

FeatureMVCC (FDB, PostgreSQL)Locking (older MySQL)
Reads block writersNoYes (shared lock)
Writers block readsNoYes (exclusive lock)
DeadlocksNot possiblePossible (lock cycles)
Stale readsPossible if version too oldNot possible
OverheadGarbage collection of old versLock 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.

OperationSemanticsUse case
Add(key, n)Atomic 64-bit integer additionCounters, sequence numbers
BitAnd/Or/XorBitwise operation on the valueBit set membership
Max/MinAtomic max or min of the current valueHigh-water marks, metrics
SetVersionstampedStamp the commit version into key/valueOrdered event logs
CompareAndClearClear key if current value equals argumentExpiry, conditional delete
AppendIfFitsAppend bytes if result fits in value sizeAccumulator 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:

ParameterLimit / DefaultImplication
Max value size100 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,000Watches are not free
Max key size10 KBKeep 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/secScale horizontally by adding proxies
Storage per server~100 GB–4 TB typicalFDB 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:

  1. The real implementation (for production)
  2. 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:

  1. Record the random seed that triggered it
  2. Reproduce the exact same sequence of events with the same seed
  3. 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

β€œ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:

EraStorage engineCode moduleWhen
1.x – 6.xssd-1 / ssd-2 β€” a fork of SQLite’s B-tree (KeyValueStoreSQLite)fdbserver/KeyValueStoreSQLite.actor.cpp2013 – 2021 (default)
6.2 – presentmemory β€” in-memory radix tree with on-disk WALfdbserver/KeyValueStoreMemory.actor.cppalways (small datasets)
6.3 – presentssd-redwood-1 (Redwood) β€” purpose-built versioned B+treefdbserver/VersionedBTree.actor.cpp2020 (experimental) β†’ 2023 (production)
7.1 – presentssd-rocksdb-v1 (Sharded RocksDB)fdbserver/KeyValueStoreRocksDB.actor.cpp2022 (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:

  1. Subscribe to the Transaction Log for mutations.
  2. Buffer them in an in-memory MVCC structure called the VersionedMap (a persistent β€” i.e., immutable-snapshot β€” radix tree, see fdbserver/VersionedMap.h).
  3. Periodically write a β€œdurable version” of the VersionedMap into the underlying IKeyValueStore.
  4. 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:

  1. Before modifying page X, copy X’s current contents to a side file (*.fdb-rj).
  2. Modify page X in memory.
  3. fsync the rollback journal.
  4. Overwrite page X in the main file.
  5. 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:

  1. 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.
  2. 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.
  3. 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:

  1. The in-memory MVCC overlay (no longer needed for the 5-s window).
  2. The durable on-disk tree.
  3. 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:

  1. The mutation logically applies to page #42.
  2. A new physical page (say #9817) is allocated containing the new content.
  3. 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).
  4. The old page #42 is not freed immediately. It is kept alive until the oldest reader’s version is past it.
  5. A background actor periodically scans the page allocator’s free queue and reclaims pages whose oldest_reader_version constraint 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

FileWhat to look at
fdbserver/VersionedBTree.actor.cppclass VersionedBTree, commit(), commitSubtree()
fdbserver/DeltaTree.hDeltaTree2, the prefix-compressed in-page format
fdbserver/FIFOQueue.hThe transactional free-page allocator
fdbserver/IPager.hThe pager interface Redwood sits on
fdbserver/Pager.h, Pager.cppThe 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.cpp is 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 profileRecommended engine
Mixed read/write OLTPRedwood (default)
Write-heavy time-series / event sourcingSharded RocksDB
Small dataset, < 100 GBMemory engine
Catalog / coordinator stateSQLite (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:

  1. Walk versionedData for the key at version v. If found, return.
  2. Otherwise, read from storage (which holds the snapshot as of storageVersion).
  3. If v < oldestVersion, return transaction_too_old (error 1007).

A mutation:

  1. Update versionedData at the new version.
  2. Update latestVersion.
  3. Periodically (every ~5 ms or 5 MB, configurable via STORAGE_COMMIT_INTERVAL and STORAGE_COMMIT_BYTES knobs), flush the versionedData entries with version ≀ some cutoff into storage, then advance storageVersion.

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:

StepWhereWhat happens
1Transaction Log β†’ Storage ServerMutation (v100, set apple=1, clear banana) arrives via pull RPC
2storageserver.actor.cpp, update()Mutation appended to in-memory versionedData at v100
3Client read at v100 returning appleHits versionedData overlay β†’ returns 1 immediately, no disk I/O
4(5 ms later, STORAGE_COMMIT_INTERVAL)Storage Server calls storage->commit(v100)
5Redwood VersionedBTree::commit()Walks pending mutation buffer
6DeltaTree2 insertPage #42 (β€œa-prefix” leaf) needs apple=1 added at version v100
7Pager newPageID()Allocates page #9817 from free queue
8DeltaTree2 serializerWrites new page contents to buffer
9Parent page rewritePage #7 (internal) needs new child pointer β†’ allocate #9818
10Root page rewriteNew root page allocated
11DWALPager writePage() Γ— 3Buffered, not yet fsync’d
12DWALPager WAL record appendOne WAL record covering all page writes
13fsync on WALSingle 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

β€œ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.Asio stackful) β€” 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 a step() method.
  • state β€” marks a local variable as needing to survive across wait() calls. Plain locals are not preserved.
  • wait() β€” suspends the actor until the given Future<T> is ready, then resumes with its value. Only legal inside ACTOR functions.
  • loop β€” syntactic sugar for while (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:

  1. One thread. Every wait() resumes on the same thread as the original ACTOR invocation. No data races, no mutexes inside Flow code.
  2. Cooperative scheduling. An actor must wait() (or return) to yield. A CPU-heavy actor that doesn’t wait() will block every other actor in the process β€” including network I/O. Flow has utilities like yield(taskPriority) to voluntarily reschedule.
  3. Priorities. Every actor runs at a TaskPriority (see flow/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 via epoll/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:

KnobDefaultWhat it controls
MAX_VERSIONS_IN_FLIGHT100,000,000MVCC window in versions (~100 s of throughput)
STORAGE_COMMIT_INTERVAL0.005 sStorage Server batch commit window
STORAGE_COMMIT_BYTES10,000,000Force commit if this many bytes buffered
COMMIT_BATCHES_MEM_BYTES_HARD_LIMIT8 GBMemory cap on uncommitted batches at Proxy
MAX_READ_TRANSACTION_LIFE_VERSIONS5,000,000The 5-second read window (versions β‰ˆ 1M/sec)
RESOLVER_STATE_MEMORY_LIMIT1 GBConflict-history memory per Resolver
DESIRED_TOTAL_BYTES_PER_TLOG_QUEUE2.4 GBTLog queue soft cap
TARGET_BYTES_PER_STORAGE_SERVER1 TBData 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

  1. Boot: fdbserver creates a synthetic cluster of n processes (typically 5–15), each represented as a coroutine inside the same OS process.
  2. Inject: a Sim2 instance replaces Net2. All INetworkConnections::net() calls now return simulated connections.
  3. 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.
  4. Workload: a test file (*.toml or *.txt in tests/) specifies which clients to run (Cycle, RandomTransactionWorkload, ClogTLog, etc.) and which failures to inject (Attrition, Clogging, MachineAttrition).
  5. 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.
  6. Validation: workloads verify their own invariants (Cycle checks that a ring of writes traverses correctly). If any actor calls ASSERT(...) 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

FileWhat
fdbrpc/sim2.actor.cppThe Sim2 implementation β€” fake network, fake disk
fdbserver/SimulatedCluster.actor.cppBuilds the synthetic cluster topology
fdbserver/workloads/*.actor.cppAll 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.Database is 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. Subsequent fdb.OpenDatabase calls reuse it.
  • Future.Get() blocking. Your goroutine Get()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:

  • state variables 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

β€œ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):

Operationp50p99Notes
tr.Get(key) (cache hit)0.8 ms2.1 msdominated by CGO + loopback
tr.Get(key) (cache miss)1.5 ms4.2 msone NVMe page read
tr.GetRange(100 keys)1.2 ms3.5 mssequential pages, no extra RTTs
Empty commit2.0 ms5.5 msTLog fsync
1-key commit2.1 ms5.6 ms
100-key commit2.4 ms6.0 mswithin TLog batch
10,000-key commit (β‰ˆ1 MB)12 ms35 msbounded 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)

WorkloadThroughputNotes
Read (point, cache hit)~100,000 ops/sCGO bound
Read (range, 1 KB rows)~600 MB/snetwork bound (loopback)
Commit (empty)~5,000 commits/sTLog fsync rate
Commit (10 keys)~4,500 commits/sbarely affected by transaction size
Set (sustained, in batches of 1,000)~150,000 sets/sTLog throughput

Production-shape cluster (3 Γ— c5.9xlarge, single AZ, double replication)

Published by the FDB Summit 2021 talk β€œFoundationDB at Snowflake”:

WorkloadThroughput
Mixed 80/20 read/write small KV1.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_LIMIT knob.

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
100.1%
1001.0%
1,0009.5%
10,00063%

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:

  1. 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.
  2. 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:

ComponentLimiting metricTypical ceiling per process
Commit ProxyCPU + RPC fan-out250 K commits/s
GRV ProxyRPC throughput1 M GetReadVersion/s
ResolverCPU (conflict check)500 K commits/s
TLogfsync rate Γ— batch size50–80 K commits/s, 200 MB/s write
Storage ServerNVMe write IOPS / read CPU100 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 originBytes writtenWhy
TLog (the WAL)3 Γ— Breplication factor 3
Storage Server (durable apply)1.5 Γ— BRF 3 Γ· 2 shard-team size, plus engine overhead
Redwood B+tree page rewrites~1.5 Γ— Bcopy-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:

  1. Reads are cheap. ~1 ms p50, scale linearly with Storage Servers. If reads are your bottleneck, add Storage Servers.
  2. 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).
  3. Conflicts are quadratic in concurrency. Halve hot-key access, halve conflict rate^2 (because both the writer and the reader’s odds drop).
  4. Write amplification is ~5Γ—. Plan SSD endurance and bandwidth from that, not from application MB/s.
  5. The 5-second MVCC window bounds transaction duration. Long-running work must be chunked into bounded transactions.
  6. 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

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-layer apart end-to-end: tuple layer, directory layer, FDBRecordStore, the planner, and a byte-by-byte walk-through of one saveRecord() 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:

  1. Unambiguous decoding: given a raw key-value pair, you can reconstruct the original data
  2. Co-location: related data is adjacent in key order, enabling efficient range scans
  3. Minimal write amplification: updating one piece of data doesn’t force rewrites of unrelated data
  4. 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:

LayerSubspace layout
option-a-leveldbns + 0x00 + userKey
option-a-sqlitens + 0x00 + tableName (catalog), ns + 0x01 + tableName + 0x00 + pk (rows)
option-b-leveldbns + 0x01 + fileType + fileNum (meta), ns + 0x02 + fileType + fileNum + chunkNum (data)
option-b-sqlitens + 0x00 (size key), ns + 0x01 + pageNum (pages)
option-c-record-layerns + 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:

TypePrefix byteEncodingSort behavior
None / nil0x00just the prefixalways smallest
Byte string0x01bytes + \x00 (with \x00 β†’ \x00\xFF)lexicographic
Unicode string0x02UTF-8 + \x00 (same escaping)lexicographic
Nested tuple0x05nested encoding + \x00element-by-element
Integer 00x14just the prefixnumeric zero
Positive integer0x15–0x1C1–8 bytes big-endiannumerically ascending
Negative integer0x0C–0x131–8 bytes, bitwise NOTnumerically ascending
Float (32-bit)0x204 bytes, sign-bit-flipped IEEE 754numerically ascending
Double (64-bit)0x218 bytes, sign-bit-flipped IEEE 754numerically ascending
Boolean false0x26just the prefixfalse < true
Boolean true0x27just the prefixfalse < true
UUID0x3016 bytesUUID byte order
Versionstamp0x32/0x3312 bytescommit 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:

  1. FDB atomic ops: tr.Add("global_counter", 1) β€” atomic, no conflict, no retry needed
  2. Sharded counters: split into N shard keys; read by summing all shards
  3. 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

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:

  1. Explain, on a whiteboard, how a Record Layer saveRecord() call becomes a set of FDB key-value mutations.
  2. Read the upstream Java source and recognize the major subsystems.
  3. Decide when to use the Record Layer instead of writing your own layer.
  4. 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 main branch of FoundationDB/fdb-record-layer and FDB release-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:

CapabilityWhat it replaces
RecordStore.saveRecord(message)Hand-rolled tuple encoding
Protobuf-defined schemasmap[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 continuationsPagination glue code
Online index buildsDowntime to add an index
Multi-tenancy via KeySpacePathStringly-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:

  1. Tuple layer β€” order-preserving binary encoding of typed tuples. Source: com.apple.foundationdb.tuple.Tuple in the Java bindings.
  2. 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.
  3. 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 tagMeaningNotes
0x00null
0x01byte stringterminated 0x00, internal 0x00 escaped to 0x00 0xFF
0x02UTF-8 stringsame escaping
0x05nested tuplerecursive encoding
0x0B–0x1Dintegerlength-prefixed big-endian, with a clever sign trick
0x20IEEE 754 floatsign bit flipped + two’s-complement for negatives
0x21IEEE 754 doublesame trick
0x26 / 0x27false / true
0x30UUID
0x33Versionstamp80-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 0x00 separators 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)ValueWhy
1S / 0 / 0store header (read first)format & schema version check
2S / 1 / 1 / 42 β‡’ split index 0first 100 kB of serialized protobufthe record itself, primary-key encoded
3S / 1 / 1 / 42 / split=1next 100 kB if record largerecord splitting for >100 kB records
4S / 2 / "email" / "alice@..." / 1 / 42emptyvalue index entry on email
5S / 3 / 1 / 42 β‡’ <versionstamp>12-byte versionstamprecord version for __version queries
6S / 4 / "User" / 42emptyrecord-type index (if enabled)
7tr.AtomicAdd(S / 5 / "recordsByType" / 1, 1)+1aggregate count index
8tr.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:

  1. 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.
  2. 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 typeKey shapeUse 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”
countatomic ADD on a single keyrow counts
sum, min, maxatomic on a single keyaggregates
textinverted index, one key per (token, pk)full-text search
permuted_min/maxmaintains the min/max under a groupingleaderboards
unique modifier on valueadds write conflict on the index keyenforce 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() and commit.
  • 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:

ConceptApple Record Layer (Java)This repo (Go)
SchemaProtobuf .proto + RecordMetaDataSchema{Name, Indexes []string} literal
RecordMessage (protobuf)Record = map[string]any
EncodingTuple layer (TupleUtil)hand-rolled 0x00 separator, msgpack value
Primary keytuple from annotated fieldsstring parameter to PutRecord(schema, pk, rec)
Index maintenanceIndexMaintainer per typeinline loop over sc.Indexes in store.go
Query plannerRecordQueryPlannerone method per access path (GetRecord, LookupByIndex)
ContinuationRecordCursor.getContinuation()none β€” scans return whole slice
Online index buildOnlineIndexernot implemented
VersionstampsSetVersionstampedKey atomic opnot 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:

  1. Read the old record (to find its old index entries).
  2. Clear those index entries.
  3. Write the new record bytes.
  4. 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.

  1. bindings/java/src/main/com/apple/foundationdb/tuple/TupleUtil.java β€” the tuple encoding. Everything is downstream of this.
  2. com.apple.foundationdb.subspace.Subspace and com.apple.foundationdb.directory.DirectoryLayer β€” namespacing.
  3. com.apple.foundationdb.record.metadata.RecordMetaData and RecordMetaDataBuilder β€” how a .proto becomes runtime metadata.
  4. com.apple.foundationdb.record.provider.foundationdb.FDBRecordStore β€” saveRecord, loadRecord, deleteRecord. The core. Skim, don’t memorize.
  5. com.apple.foundationdb.record.provider.foundationdb.indexes.ValueIndexMaintainer β€” the simplest index maintainer; read top to bottom.
  6. com.apple.foundationdb.record.query.plan.RecordQueryPlanner β€” the planner. Read just plan(RecordQuery) first, then drill into one rule.
  7. com.apple.foundationdb.record.provider.foundationdb.OnlineIndexer β€” chunked index build with continuations. Beautiful, real example of the 5-second-rule pattern.
  8. The integration tests under fdb-record-layer-core/src/test β€” they are the best documentation. FDBRecordStoreTestBase has 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.

  1. Why does the tuple layer use 13 different type codes for integers instead of always using 8 bytes?
  2. How does the Record Layer keep a secondary index consistent with the record it points at, even under concurrent writes?
  3. What is a continuation, and why does it exist? (Hint: 5-second transaction limit.)
  4. A user wants to add a new index to a 1 TB record store without downtime. Walk through what OnlineIndexer does.
  5. 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

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 .proto files)
  • 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:

  1. 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 version
    

    A reader at snapshot V only reads pages at version <= V. The GC process cleans up versions older than the oldest active reader.

  2. 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.

  3. 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.

  4. Namespace mapping: SQLite filenames map to FDB key prefixes. ATTACH DATABASE 'db2.mvsqlite' AS db2 opens 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

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

OrderFolderCore concept learnedDifficulty
1option-a-leveldbSubspaces, MVCC snapshots, write batches, iterators⭐⭐
2option-c-record-layerSecondary indexes, index consistency, PK encoding⭐⭐⭐
3option-a-sqliteTable catalog, row encoding, column types⭐⭐⭐
4option-b-sqlitePage model, byte-range I/O, partial-page RMW⭐⭐⭐⭐
5option-b-leveldbFile 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

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 cache
  • fdbserver/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

β€œ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

BinaryPurpose
bin/fdbserverThe server daemon β€” every cluster role runs this binary
bin/fdbcliInteractive cluster admin client
bin/fdbbackup, fdbrestoreBackup tooling
lib/libfdb_c.soThe client library β€” what Go/Java/Python bindings load
bin/joshua_agentDistributed simulation test runner
bin/makoLoad 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

  1. 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.
  2. Sign the CLA. The bot will prompt on your first PR. Individual CLA is fine; corporate CLA needed if your employer holds your IP.
  3. Branch from main. Topic branches like fix-storage-leak-12345.
  4. One logical change per PR. Refactors stay separate from bug fixes. Maintainers reject mixed PRs quickly.
  5. 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.
  6. Run the relevant simulation tests locally before pushing:
    ctest -L fast -j$(nproc)         # ~15 min, runs the "fast" test suite
    
  7. 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-format using the repo’s .clang-format. Run ./contrib/clang-format-hook.sh or set up the pre-commit hook.
  • No std::unordered_map, no raw gettimeofday, no std::thread. The determinism rules from Chapter 4 are enforced in review.
  • Knobs go in Knobs.h with 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.

VenueUse for
forums.foundationdb.orgDesign discussion, β€œhow does X work” questions
#foundationdb on Apache SlackQuick chat (request invite via forum)
GitHub DiscussionsFeature 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:

  1. Documentation: clarify a confusing section. Pick a doc page that confused you while reading this book. Improve it. Submit it.
  2. Add an example to the binding tests. Each language binding has a test/ subdirectory; many corner cases are under-tested. Add a test for tr.GetEstimatedRangeSize from your favorite binding.
  3. 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.h to be actionable.
  4. Add a knob. Find a hardcoded constant in fdbserver/ that looks like it should be tunable (search for magic numbers in .actor.cpp files). Move it to Knobs.h, give it a default, and add a release note.
  5. 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.
  6. 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.
  7. 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.

Even before contributing, reading the source is the best self-education in distributed systems available outside graduate school. A recommended sequence:

  1. 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.”
  2. fdbrpc/ β€” RPC framework on top of Flow.
    • FlowTransport.actor.cpp β€” sending/receiving requests.
    • sim2.actor.cpp β€” the simulation backend.
  3. fdbserver/ β€” read in this order:
    • MasterServer.actor.cpp (or SequencerServer.actor.cpp in 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, then DeltaTree.h.
    • ClusterController.actor.cpp β€” the role manager (last; it’s complex).
  4. fdbclient/ β€” how clients see the cluster.
    • NativeAPI.actor.cpp β€” transaction lifecycle.
    • MultiVersionTransaction.actor.cpp β€” the client-version compatibility dance.
  5. 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:

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:

ConceptLevelDBThis layer (over FDB)
PutAppend to MemTable + WAL on local diskSet inside tr.Transact{...}
GetMemTable β†’ immutable tables β†’ SSTstr.Get (consistent across the cluster)
BatchSingle WAL recordOne FDB transaction (cross-key atomic)
RangeLevelDB SST merging iteratortr.GetRange over a Subspace
SnapshotPins on-disk sequence numberSetReadVersion(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) }). Transact handles automatic retry on conflict.
  • Get(k) β†’ db.ReadTransact(func(rt){ rt.Get(ns.Pack(k)).Get() }). An empty FDB result (nil) becomes our ErrNotFound.
  • Delete(k) β†’ tr.Clear(ns.Pack(k)) (no-op if absent).
  • Batch.Write β†’ a single Transact containing many Set/Clear ops. Either all of them land, or none do.
  • Iterator β†’ materialized list from tr.GetRange(subspace.Range()). Real production code would stream via RangeIterator, but materializing keeps the iterator usable outside a live transaction (the LevelDB shape).
  • Snapshot β†’ capture the read version with tr.GetReadVersion(), then on subsequent reads call tr.SetReadVersion(captured) so FDB serves the data as it was at that point in time.

Running

  1. Start the shared FDB cluster (from the repo root):
    docker compose up -d
    ./scripts/bootstrap-fdb.sh
    
  2. Install the FDB client library on the host (required by the Go bindings, which are CGO-linked to libfdb_c):
    brew install foundationdb        # macOS
    
  3. 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

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

  1. What LevelDB Is (and Isn’t)
  2. The Five Concepts: Get, Put, Delete, Batch, Iterator, Snapshot
  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. How the Demo Works 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

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

OperationUse whenFDB primitive
GetReading a single keyReadTransact
PutWriting a single keyTransact + Set
DeleteRemoving a single keyTransact + Clear
BatchWriting 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 Set and Clear calls 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 ReadTransact or Transact calls concurrently β€” these are correctly serialized by the binding
  • fdb.Database is 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-layer did.

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 GetRange on <ns> 0x01 <tableName> 0x00 is 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_module C ABI (via CGO with mattn/go-sqlite3, or using modernc.org/sqlite’s yet-undocumented vtab hooks).
  • Mapping SQLite’s xBestIndex / xFilter / xColumn callbacks 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

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

  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 a 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: SQLite, MySQL, PostgreSQL
  12. Exercises

1. What a Relational Database Actually Stores

A relational database is, at its core, a system for:

  1. Storing rows (collections of typed values) under a primary key.
  2. Keeping a catalog (metadata about what tables exist and their schemas).
  3. Maintaining indexes (secondary data structures for fast lookup by non-primary-key fields).
  4. 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:

  1. Read the old row (to find out what old index entries to remove)
  2. Clear the old index entries
  3. Write the new row
  4. 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:

  1. Scan secondary index for city = 'Paris' β†’ get pk values
  2. 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 entries
  • 0x01 β†’ row data
  • 0x02 β†’ 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

Pattern: existing storage engine, FDB as the disk. We give the unmodified goleveldb library an FDB-backed implementation of its storage.Storage interface. 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.
  • Create returns a Writer that buffers in memory and flushes on Sync / Close. We split the flush across multiple transactions (100 chunks each β‰ˆ 6 MiB) to safely handle files larger than 10 MB.
  • Rename is implemented as copy-then-clear inside one transaction. LevelDB only renames small files (temp β†’ real on flush completion), so the inefficiency doesn’t matter.
  • SetMeta writes 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 Open is 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

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.Storage interface, why does it exist, and what does it tell us about how databases handle file I/O?”


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
  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: RocksDB on Cloud Storage
  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

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:

  1. LevelDB picks some SSTables, merges and sorts them into a new SSTable.
  2. It writes the new SSTable as a .tmp file (via Create(TypeTemp, ...))
  3. It renames the .tmp to the final .ldb name (via Rename)
  4. It updates the MANIFEST to list the new SSTable and de-list the old ones.
  5. 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 expectsWe 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.ReadSeekerone 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:

ConstantValuePurpose
TypeManifest0x01Tracks which SSTables are live (replaces CURRENT file)
TypeJournal0x02Write-Ahead Log β€” records every write before memtable flush
TypeTable0x04SSTable β€” sorted, immutable on-disk data file
TypeTemp0x08Scratch file used during compaction, then renamed
TypeAll0x0FBitmask 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:

LayerWhat it indexesHow it searches
FDBFile #5, chunks 0–NKey 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:

RuleWhy
All 22 records go into the same SSTableOne memtable β†’ one flush β†’ one file
The SSTable is written once, in fullflush() fires on Close() only
The SSTable is immutable forever afterSSTables are never modified
A new write to "alex" does NOT update SSTable #3It goes into the next memtable β†’ SSTable #5
Two versions of "alex" can exist simultaneouslyLevelDB 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/... β†’ tag 0x01
  • /data/... β†’ tag 0x02
  • /manifest β†’ tag 0x03
  • /lock β†’ tag 0x04

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 all 0x02 (data) keys β€” because 0x01 < 0x02.
  • Within the 0x01 band, 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:

  1. Create + Close produces exactly one meta key (0x01) and one chunk key (0x02).
  2. List finds the file by scanning only 0x01 keys.
  3. Open reconstructs the payload exactly by reading the 0x02 chunk.
  4. SetMeta / GetMeta round-trips through the single 0x03 key.
  5. Remove clears both the 0x01 meta key and all 0x02 chunk 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 prefixTagWhat it representsRead by
73696d00Β·01Β·01·…0x01MANIFEST file exists, size=NOpen, List
73696d00Β·01Β·02·…0x01Journal file exists, size=NOpen, List
73696d00Β·01Β·04·…0x01Table file exists, size=NOpen, List
73696d00Β·02Β·01·…·chunk0x02MANIFEST file contentOpen only
73696d00Β·02Β·02·…·chunk0x02Journal file contentOpen only
73696d00Β·02Β·04·…·chunk0x02Table file contentOpen only
73696d00Β·030x03Current MANIFEST pointerGetMeta 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 name
  • Rename(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:

  1. Break the rename into multiple transactions (violating atomicity), or
  2. 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:

  1. Write all content to a temp file
  2. 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:

  1. Switch to an in-process lock β€” replace the FDB key with a sync.Mutex flag on the Storage struct. 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.

  2. Avoid log.Fatal after defer db.Close() β€” extract the code into a function that returns an error. Deferred cleanup runs normally when the function returns, even on the error path. The caller then does log.Fatal after 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:

  1. Check for a β€œrename-pending” key (Exercise 4 in this guide) and complete or roll back any in-flight rename.
  2. Use goleveldb’s Recover function (instead of Open) which reads whatever SSTables it can find and rebuilds the MANIFEST from scratch.
  3. 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

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 callWhat 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 / xUnlockFile.Lock(holder) / File.Unlock(holder)
xSyncno-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:

  1. Issue tr.Get for every affected page.
  2. Merge the existing page bytes with the new bytes.
  3. tr.Set the resulting full page.
  4. 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:

  1. cgo + mattn/go-sqlite3 or zombiezen.com/go/sqlite: register a custom sqlite3_vfs whose xRead/xWrite/... thunks call into our pagestore.File methods via a CGO bridge. ~300 lines of glue.
  2. modernc.org/sqlite: pure-Go SQLite. Its vfs subpackage exposes Register / VFS types. 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.md above for the wiring sketch.

Hitchhiker’s Guide β€” Option B: SQLite VFS over FoundationDB

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

  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: libSQL, Litestream, LiteFS
  11. 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:

  1. Written to FDB’s transaction log on f+1 machines (durably replicated)
  2. 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:

  1. A small FDB key range to store the WAL index state.
  2. 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:

  1. Add a β€œWAL region” subspace: ns + 0x03 + regionNum β†’ 32768 bytes
  2. xShmMap(region, size) reads the FDB key and returns a []byte
  3. xShmBarrier() writes the modified byte slice back to FDB
  4. xShmLock maps 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:

  1. Lists all databases (range scan over a well-known metadata prefix)
  2. Creates a new database (allocates a namespace, writes a metadata entry)
  3. Deletes a database (one ClearRange + metadata clear)
  4. Returns a pagestore for 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:

  1. Write some data.
  2. Kill the process mid-write (use kill -9 or os.Exit(1) inside a WriteAt).
  3. Restart the process and open the database.
  4. 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

Pattern: β€œstructured records + secondary indexes, native FDB” β€” the same idea Apple’s fdb-record-layer is 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:

  1. Read the previous version of the record (if any).
  2. For each indexed field that changed or was removed, Clear the old index entry.
  3. Set the new record bytes.
  4. Set fresh 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

  1. Bring up FDB and bootstrap (see top-level README).
  2. 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, SUM aggregates.
  • 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

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

  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: MySQL, PostgreSQL, MongoDB
  10. 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:

  1. Remove the old index entry for city=Paris, pk=alice
  2. Add a new index entry for city=Tokyo, pk=alice
  3. 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) βŠ† presentIndexKeys
  • forall indexKey: correspondingRecord exists

Both directions. Our PutRecord maintains this invariant within one transaction:

  1. Remove old index entries β†’ breaks β€œno stale entries” for old value
  2. Write new record β†’ old record value is overwritten
  3. 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:

  1. Protobuf-typed records (schema validation, efficient encoding)
  2. Value indexes, rank indexes, text indexes, aggregate indexes
  3. A query planner that selects the best index for a given predicate
  4. Change feed via versionstamped index keys (see GUIDE.md chapter on versionstamps)
  5. 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:

  1. Range-scan the secondary index to get PKs.
  2. 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:

  1. Start writing new index entries in PutRecord immediately (for new and updated records).
  2. In a background worker, page through all existing records (using the continuation pattern) and write their index entries.
  3. 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.

LabTopic
Lab 01Write-Ahead Log (WAL)
Lab 02MemTable + Internal Keys
Lab 03Write Path: WAL β†’ MemTable + Crash Recovery
Lab 04SSTable (Sorted String Table)
Lab 05MemTable Flush to L0
Lab 06Compaction and Merged Iterator
Lab 07Snapshots, Compatibility Layer, and Benchmarks
Lab 08LevelDB 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?

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:

PhaseWhat happensTypical cost
SeekActuator arm moves to the correct cylinder3–9 ms (avg ~5 ms)
Rotational latencyWait for target sector to rotate under head0–8.3 ms (avg ~4 ms)
TransferDMA 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)
TypeFull nameBits/cellVoltage windowsP/E enduranceNotes
SLCSingle-Level Cell1250,000–100,000Enterprise WAL / cache drives
MLCMulti-Level Cell243,000–10,000High-endurance enterprise
TLCTriple-Level Cell381,000–3,000Most consumer SSDs today
QLCQuad-Level Cell416100–1,000High-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:

  1. 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).
  2. 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:

  1. Allocates a fresh page (e.g., Die 0, Plane 1, Block 29, Page 1).
  2. Programs the new data there.
  3. Updates the L2P table: LBA 42 β†’ Block 29, Page 1.
  4. 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.

WorkloadWrite Amplification Factor (WAF)
Pure random 4 KB overwrites10×–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_APPEND atomicity covers a single write() call. Our Append does two writes (header, then payload). If two goroutines shared the WAL and called Append concurrently, 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 use flock / fcntl locks or a per-writer fd with a merge step. (flock(2) grants advisory exclusive or shared locks on an entire file; fcntl(2) with F_SETLK / F_SETLKW provides 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.

LayerWhat it protects
ext4 data=writebackMetadata only; data may contain stale bytes after crash
ext4 data=ordered (default)Data flushed before journal commit; no content guarantee
ext4 data=journalData and metadata both journalled; ~2Γ— write amplification
Application WALApplication-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 one bio by 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), and prp / 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), phase bit (toggles each ring wrap so software can tell new CQEs from old ones), and cid (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 special IN/OUT port 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 (opcode 0xEA)
  • NVMe: FLUSH admin command (opcode 0x00)
  • SCSI: SYNCHRONIZE CACHE(10)

fsync vs fdatasync vs sync_file_range

SyscallData flushedMetadata flushedNotes
sync()All dirty pages, system-wideYesShutdown / unmount
fsync(fd)All dirty pages for fdYes β€” mtime, ctime, sizeMaximum safety
fdatasync(fd)Data pages for fdOnly if required to read dataWAL β€” slightly faster
sync_file_range(fd, off, len, flags)Specified byte rangeNoPipelined 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_DIRECT is not supported on macOS. Use fcntl(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:

  1. Acknowledge I/O to the host immediately (reducing host-visible latency).
  2. 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_range is not in POSIX. macOS/BSD have no equivalent. Portable code falls back to fdatasync.

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() with IORING_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 -errno on 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 per io_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 the fsync SQE 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, ...) returns EPERM.
  • 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:

MechanismPlatformNotes
io_uringLinux 5.1+Recommended; zero-syscall hot path, full async
POSIX AIO (aio_write / aio_fsync)POSIXOften implemented with threads internally
libaio (kernel AIO)LinuxLow-level; used by PostgreSQL, MySQL for O_DIRECT
kqueue + AIOBSD/macOSPlatform-specific
Thread poolAllGo 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 before Sync returned.
  • 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 Append that returned nil is guaranteed to be recovered. Any Append that did not return nil may 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.

AlgorithmStrengthHW accelerationUsed by
CRC32 (IEEE)32-bitNone on most CPUsOur WAL
CRC32C (Castagnoli)32-bit, better polynomialSSE4.2 _mm_crc32_u8, ARM CRC extLevelDB, RocksDB, NVMe, iSCSI
xxHash-64Not a CRC; good general hashSIMD-acceleratedClickHouse
XXH3-128128-bit, very fastAVX2Some 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:

  • writev requires building an iovec array β€” a small but real cost per call. An iovec is a { void *iov_base; size_t iov_len; } pair describing one buffer segment; writev accepts an array of them and transfers all segments atomically in one syscall.
  • The fdatasync that follows dominates latency by 3–4 orders of magnitude (~100 Β΅s NVMe, ~10 ms HDD vs. ~100 ns for an extra write() 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):

  1. Read 8 bytes β†’ parse length and checksum.
  2. Read exactly length bytes β†’ that is the complete payload.
  3. Verify CRC32; if mismatch, stop.
  4. File cursor is now exactly at the start of the next record.
  5. 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).

PropertyLength-prefixDelimiter
Any byte value in payloadβœ“Requires escaping or forbidden bytes
O(1) skip to next recordβœ“ β€” add length to current offsetO(n) β€” scan until delimiter found
Detect truncation preciselyβœ“ β€” ReadFull fails exactly at boundaryOnly at delimiter position
Parsing overheadZero β€” one integer readEscape decode on every byte
Seek to arbitrary record NO(N) β€” must scan from startO(N)

Who else uses this framing

Every major binary protocol and serialization format uses length-prefix framing:

SystemFrame headerNotes
Protocol BuffersVarint field tag + varint lengthPer-field framing within a message
gRPC1B compressed flag + 4B uint32 lengthPer-message over HTTP/2
LevelDB / RocksDB log7B: CRC32 + len + RecordType32 KB blocks; records span blocks
PostgreSQL WALXLogRecord.xl_tot_len (uint32)Plus CRC32C over the full record
MySQL binlog19B event header with data_lengthIncludes timestamp, server_id
Kafka8B offset + 4B batch sizeRecordBatch framing
Redis RDBType byte + length encodingSpecial encoding for small integers
NVMe NVM Command SetNLB 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?

A storage engine’s in-memory write buffer needs four properties at the same time:

OperationRequired?ArrayHash mapBalanced BSTSkip list
Ordered iterationYesO(n log n) sortβœ— (unordered)O(n)O(n)
Point lookupYesO(n)O(1) avgO(log n)O(log n) avg
InsertYesO(n) shiftO(1) avgO(log n)O(log n) avg
Concurrent reads without locksDesirableHardHardHardAchievable

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:

  1. Every node is red or black.
  2. The root is black.
  3. Every leaf (nil sentinel) is black.
  4. A red node’s children are both black (no two consecutive red nodes).
  5. 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.RWMutex on 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 β‰₯ x or next == nil: drop down one level.
  • At level 0, if next.key == x: found.
  • At level 0, if next.key > x or next == 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:

Structuren=40,000 lookup costWhy
Sorted array (binary search)~15 comparisons Γ— 2–4 ns = 30–60 nsContiguous memory; hardware prefetcher active; ~95% L1/L2 hit
Skip list~8–15 pointer hops Γ— 4–50 ns = 32–750 nsRandom heap addresses; each dereference a potential cache miss
Hash map~1 lookup Γ— 4–50 ns = 4–50 nsO(1) but no ordering
Red-black tree~logβ‚‚(40,000) β‰ˆ 15 comparisons Γ— 4–50 ns = 60–750 nsSame 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

OperationAverageWorst caseNotes
SearchO(log n)O(n)Worst case probability: p^n β€” negligible
InsertO(log n)O(n)Same search cost + O(h) splice
DeleteO(log n)O(n)Same as insert; not needed β€” tombstones instead
Iterator (full scan)O(n)O(n)Level-0 linked list traversal
SpaceO(n) expectedO(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):

  1. Builds lookup key ("name", seq=2, typeValue).
  2. Skip list positions on the first internal key β‰₯ that lookup.
  3. Due to descending seqNum sort: ("name", seq=3) sorts before ("name", seq=2). The first key β‰₯ lookup is ("name", seq=2) itself.
  4. seq=2 ≀ readSeq=2 and type=typeValue β†’ return β€œAlice”. βœ“

The same reader calling Get("city", 2):

  1. Lookup key: ("city", seq=2, typeValue).
  2. First key β‰₯ lookup: ("city", seq=2) β†’ seq=2 ≀ readSeq=2, typeValue β†’ β€œLondon”. βœ“

A reader at readSeq=4 calling Get("city", 4):

  1. Lookup key: ("city", seq=4, typeValue).
  2. 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_xmin committed AND t_xmin ≀ T (the insert is visible)
  • t_xmax == 0 OR t_xmax > T OR t_xmax aborted (not yet deleted)

This is exactly our model with different names:

PostgreSQLOur engine
t_xminseqNum of the Put
t_xmaxseqNum of the Delete tombstone
TransactionIduint64 seqNum
VACUUMcompaction (lab 06)
heap fileskip 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).
FeaturePostgreSQLOracle/InnoDBLevelDB/RocksDB
Old version locationHeap file (inline)Undo log (separate)Skip list / SSTable (inline)
Latest version lookupIndex β†’ heap O(log n)Index β†’ row O(log n)Skip list O(log n)
Old version lookupHeap scanUndo chain O(versions)Seek O(log n)
Cleanup mechanismVACUUM (background)Undo purge (background)Compaction (background)
Write amplification1Γ— per tuple1Γ— row + 1Γ— undo1Γ— 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"
  1. Build lookupKey = ("name", seq=2, TypeValue).
  2. CompareInternal sorts 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).
  3. Skip list seek stops at [1] (last node < lookupKey), so n = [2] which is ("name", seq=3).
  4. uk="name" == userKey, but seq=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:

  1. Iterates the full MemTable β€” showing internal-key sorted order (userKey ASC, seqNum DESC within each user key).
  2. 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=10 returns 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

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:

  1. Every Put / Delete is first serialised into a WriteBatch.
  2. The batch is appended to the WAL (lab 01) before touching memory.
  3. The batch is replayed into the MemTable (lab 02).
  4. 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 atWAL stateMemTable stateRecovery outcome
T1–T3No recordNot updatedRecover sees nothing; write never acknowledged β€” no data loss
T3–T4Partial recordNot updatedRecover stops at CRC failure; write never acknowledged β€” no data loss
T4–T5Full recordNot updatedRecover replays record; data restored βœ“
T5–T6Full recordUpdatedRecover replays record (idempotent); data intact βœ“
After T7Full recordUpdatedNormal 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.Put wraps a single operation per batch and advances seqNum by 1. The DB.Write API allows multi-op batches. The test TestBatchWrite exercises 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 pointWAL state on diskRecovery
During b.Encode()No bytes writtenRecover sees empty file; seqNum=0; fresh start
During pwrite of frame header0–7 bytes of new frameFrame length unreadable or zero; Recover stops before this frame
During pwrite of payloadFrame header + partial payloadCRC check on partial payload fails; Recover stops at this record
During fdatasyncFull record in kernel bufferDepends 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 ReplayFull record on diskRecover finds and replays it; MemTable restored correctly
After Replay, before seqNum++Full record on diskSame as above; seqNum reconstructed from WAL
After seqNum++, before returnAll in-memory state consistentNo 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:

  1. Collects all queued batches.
  2. Encodes them into a single WAL write.
  3. Calls fdatasync once.
  4. 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: seqNum is a uint64 in the DB struct, incremented under db.mu on each commit.
  • In FDB: CommitVersion is 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

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:

  1. 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.
  2. 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 (MergedIterator merges multiple SSTIters).
  • Lab 08 full-database scan (NewIterator).

Read amplification: why a single SSTable isn’t enough

After a MemTable flush, Get("k") checks:

  1. Active MemTable (RAM)
  2. Immutable MemTable, if flushing (RAM)
  3. 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

Labs 01–04 can now write durable SSTables. Lab 05 wires the flush trigger into the write path, making the engine self-managing:

  1. Each Put/Delete appends to the WAL and updates the active MemTable.
  2. After every write, maybeFlush() checks mem.ApproximateSize().
  3. 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 writes imm to a new L0 SSTable.
  4. After the flush completes, the SSTable path is written to the MANIFEST (atomically via rename), imm is set to nil, 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 atWAL segments on diskMANIFESTRecovery
Mid-freeze (step 3–5)CURRENT.wal + partial renameOld manifestReplay CURRENT.wal; MemTable rebuilt
After freeze, before SST writeCURRENT.wal + 000003.walOld manifestReplay both WALs; no data loss
After SST write, before manifest updateCURRENT.wal + 000003.wal + 000003.sstOld manifestReplay both WALs; SST is ignored (not in manifest)
After manifest update, before WAL deleteCURRENT.wal + 000003.walNew manifest (has 000003.sst)Replay CURRENT.wal; 000003.wal is stale but safe to keep
After WAL deleteCURRENT.walNew manifestNormal 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

  1. Write data, flush (via Close).
  2. Delete CURRENT.wal (simulate crash after WAL was truncated).
  3. Reopen the DB.
  4. 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

  1. Write 10 keys, close.
  2. Open again β€” MANIFEST lists the L0 SSTable.
  3. Read back all 10 keys.
  4. 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

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:

  1. 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.
  2. 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.
  3. 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

  1. Write 3 versions of β€œapple” across 3 separate flushes (each flush > threshold).
  2. Wait for the 4th flush (or trigger manually) which causes compaction.
  3. After compaction, Get("apple") returns only the newest version.
  4. Open all L1 SSTable files directly and verify each user key appears exactly once β€” no duplicate versions survive in L1.

TestTombstoneNotVisible

  1. Put("ghost","value") β†’ flush.
  2. Delete("ghost") β†’ flush.
  3. Trigger compaction.
  4. Get("ghost") returns (nil, false).
  5. 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

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

AnomalyDescriptionPrevented by MVCC?
Dirty readRead uncommitted data from an in-progress writeβœ“ Yes β€” uncommitted writes have no seqNum yet
Non-repeatable readRead same key twice, get different valuesβœ“ Yes β€” snapshot seqNum is fixed
Phantom readRange scan sees different rows on second scanβœ“ Yes β€” snapshot iterates fixed seqNum
Write-write conflictTwo writers overwrite each otherβœ— No β€” lab07 has no write transactions
Lost updateRead-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:

  1. The write at N is complete: db.seqNum = N+1, snapshot sees readSeq=N, which includes the write at N. βœ“
  2. The write at N is not yet started: db.seqNum = N, snapshot sees readSeq = N-1, which does not include the incomplete write. βœ“
  3. The write is in progress (between WAL append and seqNum++): the write holds db.mu during seqNum++; GetSnapshot() also calls db.SeqNum() which returns db.seqNum under 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 either N (before the increment) or N+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() sees db.seqNum = N+1, returns snap.seqNum = N.
  • snap.Get("k") uses readSeq = N β†’ same result. βœ“

For a snapshot taken before the Put:

  • GetSnapshot() sees db.seqNum = N, returns snap.seqNum = N-1.
  • snap.Get("k") uses readSeq = N-1 β†’ does not see the Put. βœ“ 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

  1. Write keys β€œa”, β€œb”, β€œc”.
  2. Take snapshot S1.
  3. Write keys β€œd”, β€œe” and delete β€œb”.
  4. Iterate S1 β†’ sees β€œa”, β€œb”, β€œc” (no β€œd”, β€œe”; β€œb” not deleted).
  5. 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

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:

SubsystemSourceFrom which lab
WAL (append-only log)wal.golab01 pattern
MVCC internal keykey.golab02 pattern
Skip list MemTablememtable.golab02 pattern
SSTable builder/readersst.golab04 pattern
Double-buffer flushdb.go:maybeFlushLockedlab05 pattern
K-way merge iteratoriter.golab06 pattern
Snapshot isolationdb.go:GetSnapshotlab07 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 stateWhy
db.memWritten by the write path; read by the flush goroutine
db.immSet by maybeFlushLocked, cleared by flushImm
db.walRotated by maybeFlushLocked
db.seqNumIncremented by every write
db.manifest.LevelsUpdated by flush/compact
db.bgErrSet 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.mem and db.imm point 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

Aspectlab03 WriteBatchlab08 WAL record
GranularityBatch of N operationsOne operation
Header8B seqNum + 4B count8B seqNum + 1B type + 4B keyLen
Value lengthExplicit 4B prefixImplicit (record length - key length - 13)
Use caseAtomic multi-key writesSimple 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

CategoryTestsWhat’s verified
Basic APITestPutGet, TestDelete, TestOverwriteKey, TestDeleteNonExistentCore read/write correctness
DurabilityTestPersistenceAcrossReopen, TestFlushAndReopen, TestWALCrashRecoveryData survives process restart
CompactionTestCompaction, TestCompactionDeduplicates, TestTombstoneNotVisible, TestCompactProducesNonOverlappingL1Merge correctness
ConcurrencyTestConcurrentWrites, TestConcurrentReadsRace-condition detection
SnapshotsTestSnapshotIsolation, TestSnapshotIterator, TestSnapshotRelease, TestSnapshotScanIsolationMVCC correctness
RecoveryTestManifestRecovery, TestWALCrashRecovery, TestSeqNumRestoredAfterReopenCrash safety
Error handlingTestBgErrSurfaces, TestWriteAfterCloseError propagation
PerformanceTestManyFlushes, TestLargeValuesSustained 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:

  1. Basic Put/Get/Delete β€” confirms the core API works.
  2. Sorted iteration β€” writes 10 out-of-order keys, iterates and verifies they come back in sorted order.
  3. Snapshot isolation β€” takes a snapshot, writes new values, verifies the snapshot still sees old values.
  4. Persistence across reopen β€” writes keys, closes DB, reopens, reads back.
  5. Write throughput β€” writes 5,000 keys Γ— 256 bytes and reports keys/s.
  6. Snapshot iterator β€” range scan using a snapshot.

Tuning constants

ConstantDefaultEffect of increasingTrade-off
flushThreshold4 MiBLarger MemTable β†’ fewer L0 files, less compactionMore RAM per open DB
compactL0Trigger4 filesMore L0 files β†’ worse read amplification during burstFewer compaction pauses
l1SplitSize2 MiBLarger L1 files β†’ fewer SST files, less file-open overheadMore overlap when compacting
skip list maxLevel12More levels β†’ faster search in large MemTablesMore memory per node
skip list prob0.25Lower β†’ fewer levels β†’ less memory, more search timeTrade 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 + fdatasync is 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 -race is 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 our userKey || (seqNum<<8 | keyType).

Data Structures in LevelDB β€” Go & Python

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 LevelDB
  • InlineSkipList β€” key stored inline in the node (no extra heap allocation), CAS-based concurrent inserts without a global write lock
  • HashSkipList β€” hash table of per-bucket skip lists; O(1) point lookup, O(n) full scan
  • HashLinkedList β€” 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:

  1. DBImpl::MakeRoomForWrite() β€” if active MemTable β‰₯ write_buffer_size (4 MiB default), rotate it to immutable and open a fresh one.
  2. Background thread calls CompactMemTable() β†’ WriteLevel0Table() β†’ BuildTable() β†’ TableBuilder::Finish().
  3. 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, then fsync() 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 with innodb_flush_log_at_trx_commit=2 for 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 (-shm file, 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:

  1. Data blocks β€” 4 KiB blocks of prefix-compressed sorted records
  2. Filter block β€” optional Bloom filter (one filter per 2 KiB of data)
  3. Metaindex block β€” maps filter block name β†’ its offset
  4. 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 running
  • CompactionIterator (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:

  1. Run formation β€” fill memory with tuples, sort in RAM (quicksort)
  2. Merge β€” LogicalTapeSet creates 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 our nodeHeap.

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:

  1. Every node is red or black.
  2. The root is black.
  3. No two adjacent red nodes (a red node’s parent must be black).
  4. 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 complexityO(log n) expectedO(log n) worst-case
Memory per node~3 pointers avg (level 1–2 typical)3 child/parent pointers + sentinel
Lock-freeYes β€” CAS on next pointersHard β€” rotations touch 3+ nodes
Cache localityPoor (pointer chasing)Poor (same)
SerializationIn-order scanIn-order traversal
Used byLevelDB, RocksDB MemTablestd::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:

SubsystemWhat is keyedSource file
CFS schedulertask vruntime (CPU fairness)kernel/sched/fair.c
Virtual memoryvm_area_struct (VMA) regionsmm/mmap.c
epollfile descriptor + interest eventsfs/eventpoll.c
Ext4 extentsblock range β†’ physical blockfs/ext4/extents.c
Pipe inodesinode numberfs/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)
ReadO(log n), warm page cacheO(levels Γ— log n)
WriteRandom I/O β€” update in placeSequential I/O β€” append-only
Write amplification~2–3Γ—~10–30Γ—
Space amplificationLow (no stale versions)Medium (until compaction)
Crash safetyPage journaling or WALWAL + 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 INDEX statements.

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_PREV and PAGE_NEXT (the leaf linked list β€” same as our prev/next pointers)
  • 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 in btr0cur.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)
  • BTPageOpaqueData appended 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.c uses 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_PAGE struct in wt_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_UPDATE structs 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:

  • BTreePage struct 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

FilesystemStructureDetails
ext4htree (dir_index)2-level hash tree over directory entries; dx_root + dx_node structs in fs/ext4/namei.c
APFSObject Map + B-treeEach volume has a B-tree mapping object ID β†’ physical block; apfs_btree_node_phys struct
NTFS$INDEX_ALLOCATIONB+ tree of directory entries; INDEX_BLOCK pages with sibling links
HFS+Catalog FileB*-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

StructureHeightInsertPoint LookupRange ScanSerialization on diskUsed in this repo
Skip ListO(log n) expectedO(log n) expectedO(log n) expectedO(k) forwardFlat sorted dumpLevelDB MemTable (lab02)
Red-Black Tree≀ 2 logβ‚‚(n+1)O(log n) worst-caseO(log n) worst-caseO(k) in-orderFlat sorted dumpEquivalent MemTable substitute
B-Treelog_m(n)O(log n)O(log n)O(kΒ·m)Fixed-size pagesSQLite backend (option-a-sqlite, option-b-sqlite)
B+ Treelog_m(n)O(log n)O(log n)O(log n + k)Fixed pages + leaf linksSSTable (lab04), FDB Redwood
Min-Heaplog nO(log n) pushO(1) peekN/A (ephemeral)Not persistedCompaction K-way merge (lab06)
WAL1 (append-only)O(1) amortizedN/ASequential replayFramed 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")

StepData structureOperationLabGo source
1Internal KeyEncode "name" || (seqNum<<8|TYPE_VALUE)02lab02/key.go
2WALAppend framed record + fdatasync01lab01/wal.go
3Skip ListPut(internalKey, "Alice") in O(log n)02lab02/skiplist.go
4MemTableThin wrapper; track size for flush trigger02lab02/memtable.go
5 (flush)SSTable BuilderAdd per sorted key; varint encoding04lab04/sstable.go
6 (compact)Min-HeapK-way merge of all L0+L1 iterators06lab06/iter.go
β€”Red-Black TreeAlternative in-memory MemTable indexβ€”β€”
β€”B-TreeSQLite page storage (option-*-sqlite labs)β€”option-a-sqlite/
β€”B+ TreeSSTable β‰… 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

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?

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

LayerWhereDifficultyWhat it achieves
Go binding layerbindings/go/src/fdb/Easy β€” pure GoPrevents users from accidentally dropping options; makes intent explicit in code
C core layerfdbclient/NativeAPI.actor.cppHarder β€” C++ Actor FrameworkTrue 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:

  1. You can test it without re-compiling the C++ cluster β€” use any running FDB instance, including the pre-built package installed at /usr/local.
  2. Binding-layer changes are reviewed by a larger set of maintainers (they affect the developer API, not internal correctness).
  3. 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 scattered SetTimeout / SetRetryLimit calls 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:

  1. Open a PR with the Go binding layer changes from this workspace. βœ… (this workspace)
  2. After that merges and the API is agreed, implement fdb_transaction_soft_reset in 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:

  1. Fork apple/foundationdb on GitHub.
  2. Clone your fork.
  3. Copy fdb_retry/retry.go content into bindings/go/src/fdb/persistent.go, change package fdb_retry to package fdb, and remove the fdb. prefix from all FDB type references.
  4. Add a test file bindings/go/src/fdb/persistent_test.go.
  5. Run the existing binding tests: cd bindings/go && go test ./src/fdb/...
  6. Open the PR, reference #4091 in the description, and note that Phase 2 (the C++ fdb_transaction_soft_reset) is a follow-up.

10. Concepts you learn from this issue

ConceptWhere 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 concurrencyFDB never holds locks; conflicts are detected at commit time by comparing read/write conflict ranges
C ABI as a stability boundaryThe C header is the stable surface; all language bindings sit above it so the C++ internals can change freely
Back-off and retry semanticsWhy OnError sleeps before retrying; why the sleep duration is capped (max_retry_delay)
Builder pattern for immutable optionsWhy PersistentOptions returns copies instead of mutating in place
CGo interopHow 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

OptionPlatformFDB installTopologyBest for
AmacOSHomebrew / pkgSingle nodeDaily dev, fastest iteration
BLinux.deb / .rpmSingle nodeCI machines, Linux dev
CAnyDocker imageSingle nodeIsolated, no host install
DAnyDocker ComposeSingle nodeZero-config, uses repo files
EmacOS / LinuxBare metal3-node clusterMaximum conflict realism
FAnyDocker Compose3-node clusterCI-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 host does not work on macOS (host networking is Linux-only). Replace --network host with -p 4500:4500 and update the cluster file’s address to 127.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)

APIWhat it doesCounters reset?
tr.Reset() (buggy path)Clears everything including optionsYes β€” clock and retry count start over
SoftReset(tr, opts)Reset() then re-applies optionsYes β€” but re-application is explicit and auditable
TransactWithPersistentOptionsUses OnError internallyNo β€” 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 on PersistentOptions for this test. Add them when writing the PR version of persistent.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 confirmCommandPass condition
No orphaned datafdbcli --exec "getrange \x00 \xff"Only issue4091:* and test:* keys
Demo exits cleanlytime go run ./demoExits in < 5 s
RetryLimit(3) firesRun 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:

  1. Change package fdb_retry β†’ package fdb
  2. Remove the fdb. qualifier from all FDB type references (they are now in the same package). For example:
    • fdb.Transaction β†’ Transaction
    • fdb.Database β†’ Database
    • fdb.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

  1. Push the branch: git push origin fix/go-persistent-tx-options
  2. Open a PR against apple/foundationdb:main on GitHub.
  3. 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
  4. 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 TransactWithPersistentOptions should be a free function or a method on Database
  • Whether SoftReset needs 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:

TermMeaning
ACTORFunction that can suspend with wait(). Compiled by actorcompiler into a state machine.
stateVariable 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, and Database.TransactWithPersistentOptions to the Go bindings as Phase 1, with the C++ fdb_transaction_soft_reset as 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:

  1. Correctness β€” the fix must not introduce a regression in any binding
  2. Consistency β€” match the naming and style of existing Transact/ReadTransact
  3. Test coverage β€” both unit and integration tests
  4. Documentation β€” update doc.go or add godoc to new exported symbols
  5. 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