Option A — LevelDB API on top of FoundationDB
Pattern: “API layer above LevelDB” — we keep the familiar LevelDB surface (
Put/Get/Delete/Batch/Iterator/Snapshot) but the bytes never touch LevelDB. They live in FoundationDB instead.
Why this is interesting
LevelDB is a local embedded KV. FoundationDB is a distributed transactional
KV. Both speak (key, value) pairs over ordered byte keys, so the API shapes
are nearly identical — but the guarantees underneath are wildly different:
| Concept | LevelDB | This layer (over FDB) |
|---|---|---|
| Put | Append to MemTable + WAL on local disk | Set inside tr.Transact{...} |
| Get | MemTable → immutable tables → SSTs | tr.Get (consistent across the cluster) |
| Batch | Single WAL record | One FDB transaction (cross-key atomic) |
| Range | LevelDB SST merging iterator | tr.GetRange over a Subspace |
| Snapshot | Pins on-disk sequence number | SetReadVersion(capturedVersion) (MVCC) |
The mapping is almost mechanical, and that’s the point: it makes FDB’s primitives concrete.
Files
layer/
encoding.go Subspace: ns + 0x00 + userKey → fdb.Key, plus range helpers
db.go Open / Close / Get / Put / Delete
batch.go Batch + DB.Write (atomic multi-op)
iterator.go Forward+backward cursor over GetRange results
snapshot.go MVCC snapshot via captured read version
demo/main.go End-to-end: CRUD → batch → range → snapshot vs. live reads
Key-space layout
<namespace> 0x00 <userKey> -> <value>
Subspace (see layer/encoding.go) keeps multiple logical
databases isolated inside one FDB cluster. The 0x00 separator + the fact
that the user-key portion is appended as opaque bytes gives us the same
lexicographic ordering LevelDB users expect.
Mapping each LevelDB op to an FDB call
Put(k,v)→db.Transact(func(tr){ tr.Set(ns.Pack(k), v) }).Transacthandles automatic retry on conflict.Get(k)→db.ReadTransact(func(rt){ rt.Get(ns.Pack(k)).Get() }). An empty FDB result (nil) becomes ourErrNotFound.Delete(k)→tr.Clear(ns.Pack(k))(no-op if absent).Batch.Write→ a singleTransactcontaining manySet/Clearops. Either all of them land, or none do.Iterator→ materialized list fromtr.GetRange(subspace.Range()). Real production code would stream viaRangeIterator, but materializing keeps the iterator usable outside a live transaction (the LevelDB shape).Snapshot→ capture the read version withtr.GetReadVersion(), then on subsequent reads calltr.SetReadVersion(captured)so FDB serves the data as it was at that point in time.
Running
- Start the shared FDB cluster (from the repo root):
docker compose up -d ./scripts/bootstrap-fdb.sh - Install the FDB client library on the host (required by the Go bindings,
which are CGO-linked to
libfdb_c):brew install foundationdb # macOS - Run the demo:
cd option-a-leveldb go run ./demo -cluster ../fdb.cluster
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=<nil>)
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.