Building DBDB from Scratch — Part 10
Immutability was the design that made everything else possible.
Old nodes are never modified. Every write creates new nodes and leaves the old ones in place. The commit is a single pointer move. Readers following the old pointer always find a complete, consistent tree — even while a writer is appending to the end of the file. No corruption. No partial reads.
But there’s a cost that’s been accumulating since post 2, and this is the post where we pay it.
Every set call that overwrites an existing key leaves the old value on disk.
Every delete leaves the old node. Every commit adds a new root. None of those
bytes are ever reclaimed. The file only grows. After enough writes, most of what’s
in the file is unreachable garbage — bytes that no pointer leads to, data that
no reader will ever see again.
db["a"] = "1" * 1000
db.commit()
db["a"] = "2" * 1000 # old value is now garbage
db.commit()
After those four lines, the file contains roughly 2000 bytes of value data. Only
1000 of them are reachable. The other 1000 — the first version of "a" — are
still sitting on disk, unreferenced, behind a root pointer that no longer includes
them.
There’s no mechanism to remove them. That’s what compaction is for.
About this series
Build DBDB from scratch is a walkthrough of rebuilding DBDB, the Dog Bed Database from 500 Lines or Less. Each post focuses on one layer of the implementation.
| Part | Core idea |
|---|---|
| 0 | Project setup: pyproject.toml, smoke tests, pytest + BDD, Makefile |
| 1 | Append-only storage: superblock, write/read, root commit, flush/fsync, locking |
| 2 | ValueRef and lazy loading: get/store, BytesValueRef, UTF-8 on disk |
| 3 | Immutable tree and BinaryNodeRef: copy-on-write, node serialization, lazy children |
| 4 | Logical layer: LogicalBase + BinaryTree: lifecycle vs. algorithms |
| Interlude | End-to-end flow: one key through all layers |
| 6 | Locking across layers: the two-writer race |
| 7 | Two lines that hold everything: commit, get, set, pop |
| 8 | The thinnest layer: the DBDB facade |
| 9 | The last translation: the CLI tool |
| 10 (this post) | What immutability costs: compaction |
| Retrospective | What a database actually is |
| 12 | Replacing the BST with an AVL tree |
| 13 | Adding a B-Tree |
| 14 | Atomic, thread-safe updates |
What Compaction Does
The idea is simple: read the current live data, write it to a fresh file, replace the old file with the new one.
No old nodes. No superseded values. No unreachable roots. Just the state the database is in right now, packed as tightly as the format allows.
Before compact() can do that, though, it needs a way to walk the entire current
tree and read every live key-value pair. That’s what __iter__ and items() are
for.
def _iter_nodes(self, node):
if node:
yield from self._iter_nodes(self._follow(node.left_ref))
yield node
yield from self._iter_nodes(self._follow(node.right_ref))
def __iter__(self):
root = self._follow(self._tree_ref)
for node in self._iter_nodes(root):
yield node.key
_iter_nodes is an in-order traversal of the binary search tree: visit the left
subtree, yield the current node, visit the right subtree. Because the BST
invariant keeps smaller keys to the left and larger to the right, in-order
traversal yields keys in ascending sorted order. It reads every node that’s
reachable from the current root — exactly the live set.
items() builds on top of __iter__:
def items(self):
self._assert_not_closed()
for key in self:
yield (key, self[key])
For each key in the traversal, it does a get to retrieve the value. Each get
follows the value_ref address from the node and reads the raw bytes from disk.
This is the read side of lazy references: the value has been on disk since the
original set, and we’re reading it now for the first time in compaction.
The Algorithm
With items() in place, compact() has everything it needs.
def compact(self) -> None:
self._assert_not_closed()
self._storage.lock()
try:
db_dir = os.path.dirname(self._storage._f.name)
with tempfile.NamedTemporaryFile(dir=db_dir, delete=False) as f:
temp_path = f.name
new_db = dbdb.connect(temp_path)
try:
for key, value in self.items():
new_db[key] = value
new_db.commit()
finally:
new_db.close()
original_path = self._storage._f.name
os.rename(temp_path, original_path) # rename while lock is still held
self._storage.close() # close (and unlock) after rename
new_f = open(original_path, "r+b")
self._storage = Storage(new_f)
self._tree = BinaryTree(self._storage)
except Exception:
if "temp_path" in locals() and os.path.exists(temp_path):
os.remove(temp_path)
raise
finally:
if self._storage.locked:
self._storage.unlock()
There are five distinct steps. Each one solves a specific problem.
Step 0: acquire the exclusive write lock.
self._storage.lock()
Compaction reads the entire live dataset, copies it to a new file, and replaces
the original. That sequence has to be consistent. Without the lock, another
process could acquire the write lock between two items() yields, commit a new
key, and finish — before compaction’s rename runs. The new key would be on the
old file. After the rename, it’s gone.
self._storage.lock() acquires the same OS-level exclusive lock that set() and
pop() acquire. Any process trying to write while compaction is running blocks
on portalocker.lock() and waits. Compaction reads a stable snapshot of the
database from start to finish.
The lock is released in the outer finally:
finally:
if self._storage.locked:
self._storage.unlock()
Whatever happens — success, failure, exception mid-copy — the lock is released.
The check exists because self._storage.close() (step 4) already calls
unlock() internally; without it, the finally would try to unlock an already-
unlocked storage and double-release.
Step 1: create the temp file in the same directory.
db_dir = os.path.dirname(self._storage._f.name)
with tempfile.NamedTemporaryFile(dir=db_dir, delete=False) as f:
temp_path = f.name
The temp file has to be in the same directory as the database. Not /tmp. Not
some arbitrary location. The same directory, which means the same filesystem.
The reason is os.rename. On POSIX systems, os.rename is atomic — but only
when the source and destination are on the same filesystem. If they’re on
different filesystems, the OS falls back to a copy-and-delete sequence, which is
not atomic. Halfway through, neither the complete old file nor the complete new
file exists at the target path. A reader opening the database at that moment
would find garbage.
Putting the temp file in the same directory as the database guarantees they’re
on the same filesystem, which guarantees os.rename is atomic.
Step 2: copy the live data.
new_db = dbdb.connect(temp_path)
try:
for key, value in self.items():
new_db[key] = value
new_db.commit()
finally:
new_db.close()
Open a fresh DBDB on the temp file, write every key-value pair from the current database into it, commit, close. The new file now contains exactly the live state — no garbage, no old versions, no superseded roots.
new_db.close() lives in finally, not after commit(). If copying fails
halfway — a disk error, a corrupted value — the temp database is still closed
cleanly. The outer exception handler then removes the temp file. The original
database is untouched.
Step 3: the atomic swap — while the lock is still held.
original_path = self._storage._f.name
os.rename(temp_path, original_path) # rename while lock is still held
self._storage.close() # close (and unlock) after rename
The order here is deliberate. An earlier version of this code called close()
first, then rename(). That created a window: between releasing the lock and
completing the rename, another writer could acquire the lock, write, and commit
— and then the rename would overwrite the file with the compacted version that
doesn’t include the new write. Silent data loss.
The fix is to rename first, then close. On POSIX, you can rename an open file.
The lock is held through the rename. Any writer blocked on portalocker.lock()
stays blocked until close() releases it — at which point the writer opens
original_path and gets the already-compacted file.
original_path must be captured before close(). After the file handle is
closed, self._storage._f.name technically still holds the string, but it’s
relying on a CPython implementation detail about closed file objects. Saving the
path first is the safe choice.
What happens to processes already connected to the old file?
This is the harder question. While compaction runs, another process might have
already opened the database and be mid-read — or worse, mid-write. When
os.rename fires, that process still holds a file handle to the old inode.
On POSIX, os.rename updates only the directory entry. The old inode is not
touched. Any process with an open handle to the old inode continues reading from
it as before. The OS uses reference counting: the old inode stays alive until
every handle pointing to it is closed.
Before rename: After rename:
original_path → inode_A original_path → inode_B (compacted)
Process B has fd → inode_A Process B still reads inode_A — no crash
Compactor has fd → inode_A Compactor closes → inode_A ref drops
inode_A freed when Process B also closes
A mid-read does not break. But there is a deeper problem: after os.rename,
Process B is reading stale data. If Process B calls set() and commits, it
commits to inode_A — the orphaned file that mydb.db no longer points to. That
write is permanently lost.
The fix is for Process B to detect that the file was replaced and reopen. The mechanism: compare the inode number of the open file handle against the inode of the file currently at the path. If they differ, the file was replaced.
def is_file_replaced(self) -> bool:
try:
return os.fstat(self._f.fileno()).st_ino != os.stat(self._f.name).st_ino
except (OSError, io.UnsupportedOperation):
return False
os.fstat reads the inode of the already-open file descriptor. os.stat reads
the inode of whatever is currently at the path. If they differ, the path now
points to a different file.
DBDB checks this before every read operation and reopens transparently if needed:
def _reopen_if_replaced(self) -> None:
if self._storage.is_file_replaced():
path = self._storage._f.name
self._storage.close()
self._storage = Storage(open(path, "r+b"))
self._tree = BinaryTree(self._storage)
def __getitem__(self, key: str) -> str:
self._assert_not_closed()
self._reopen_if_replaced()
return self._tree.get(key)
For read-only operations this is sufficient: a read that sees the old file returns stale-but-consistent data; the next read detects the swap and picks up the new file. Stale reads are acceptable under snapshot isolation.
Write operations have a harder problem. __setitem__ used to look like this:
def __setitem__(self, key, value):
self._reopen_if_replaced() # 1. check — file not replaced yet
#
# [compact runs here: lock → copy → rename → unlock]
#
self._tree.set(key, value) # 2. lock acquired, but on orphaned inode
# _refresh_tree_ref reads from orphaned file
# write goes to orphaned inode → lost
This is a TOCTOU race (Time-of-Check to Time-of-Use). The check at step 1 passes,
but by step 2 the file has been replaced. The write goes to the orphaned inode —
the file that mydb.db no longer points to. The data is permanently lost.
The fix is a second check after acquiring the lock. Once the lock is held, no
compact can run (compact also requires the exclusive lock). A True result from
is_file_replaced() at that point is conclusive.
def _prepare_write(self) -> None:
self._reopen_if_replaced() # pre-lock check (narrows the window)
if self._storage.lock(): # acquire exclusive lock
if self._storage.is_file_replaced():
# Compact ran between the pre-lock check and lock acquisition.
# Reopen: self._tree.set/pop will lock + refresh on the new storage.
path = self._storage._f.name
self._storage.close()
self._storage = Storage(open(path, "r+b"))
self._tree = BinaryTree(self._storage)
else:
# First lock, no replacement. Refresh now; set/pop will skip it
# (lock() returns False when already held).
self._tree._refresh_tree_ref()
def __setitem__(self, key, value):
self._assert_not_closed()
self._prepare_write()
return self._tree.set(key, value)
After _prepare_write() returns, one of two states holds:
- No replacement: lock is held, tree ref is current.
self._tree.set()seeslocked=True, skips its own lock and refresh, does the insert. - Replacement detected post-lock: old storage closed and unlocked, new
storage and tree created.
self._tree.set()acquires the lock on the new storage (first lock, returnsTrue), refreshes from the new file, inserts.
For mid-session writes (second set() before commit()), self._storage.lock()
returns False (already held) and _prepare_write() does nothing — no reopen,
no refresh. The in-memory tree built up during the session is preserved.
For read-only processes, the next operation after compaction transparently picks up the new file. There is no API change. The caller never knows a swap happened.
Step 4: reconnect.
new_f = open(original_path, "r+b")
self._storage = Storage(new_f)
self._tree = BinaryTree(self._storage)
The compaction instance itself was one of those readers holding a handle to the
old inode. After close(), that handle is released. We open original_path
fresh — now pointing to the compacted file — and rebuild Storage and
BinaryTree around it. The caller sees nothing change.
If anything in steps 1–4 fails, the except block removes the temp file, and the
outer finally releases the lock if it’s still held.
A Tradeoff the Algorithm Doesn’t Mention
__iter__ yields keys in sorted order. That means compact() inserts them into
the new database in sorted order: "a", "b", "c", "d", …
Inserting keys in ascending order into an unbalanced BST is the worst case. Each
key is larger than all previous keys, so each insertion goes to the rightmost
position. After inserting n keys in order, the tree is a straight line leaning
right. Every lookup has to traverse all n nodes — O(n) instead of O(log n).
The compaction makes the file smaller. It may also make lookups slower, depending on what the original insertion order was. If the database was built with random keys, the original tree was reasonably balanced. After compaction, it’s skewed.
Real databases solve this with self-balancing tree structures — B-trees and their variants ensure that inserts at any order produce a tree with bounded height. DBDB’s binary search tree doesn’t rebalance. This is a known limitation, and it’s the kind of thing you’d fix before production use.
How Real Databases Handle This
DBDB’s compaction is a manual, blocking operation. You call db.compact(), it
rewrites the file, you continue. The database is effectively locked for the
duration.
Real systems do this differently.
SQLite has VACUUM, which does exactly what DBDB’s compact() does: creates a
new database file with only live data, then renames it over the old one. It’s
also a manual operation, and it locks the database for the duration.
RocksDB and LevelDB use LSM trees, where compaction is a continuous background process. Writes go into an in-memory buffer (memtable), which is flushed to disk as immutable sorted files (SSTables). Background threads merge and compact those files over time, discarding deleted and overwritten keys. The database never stops serving reads or writes while compaction runs — it runs in parallel.
PostgreSQL uses MVCC (multi-version concurrency control) and has a VACUUM
process that reclaims space from dead row versions. It can run in the background
without blocking. A more aggressive VACUUM FULL rewrites the entire table —
the blocking version, equivalent to what DBDB does.
The deeper pattern is always the same: append-only or multi-version storage creates garbage over time. Something has to collect it. The tradeoffs are in when that collection happens, who triggers it, and whether it blocks ongoing work.
What Phase 9 Also Changed
Two other things landed in this phase.
Type annotations. Every function signature across every module now has type
hints. def get(self, key: str) -> str. def commit(self) -> None. Nothing
about the runtime behavior changed — types in Python are advisory, not enforced.
But they make the code readable in a different way: you can look at a function
signature and know what it expects and returns without reading the body.
msgpack instead of pickle. BinaryNodeRef serializes nodes to bytes when
storing them to disk. Originally that used pickle. Now it uses msgpack.
# before
return pickle.dumps({...})
# after
return msgpack.packb({...})
The difference matters: pickle is Python-specific and can execute arbitrary code when deserializing (a security hazard if you don’t control the data). msgpack is a compact binary format with no execution model — it’s pure data. It’s also readable from any language that has a msgpack library.
The tradeoff is compatibility: existing databases serialized with pickle cannot be read by the msgpack code. That’s a breaking change. Any real deployment would need a migration — read with the old code, write with the new. It’s a small example of a problem every production database eventually faces: format changes and the data that predates them.
What’s Left
DBDB is complete in all the ways that matter. Storage, logical layer, binary tree, public interface, CLI tool. Compaction. Type-safe, msgpack-serialized, file-locked, append-only, commit-or-lose-it.
The tree doesn’t rebalance. The compaction blocks. The serialization format has no versioning. For a learning project, these are the right tradeoffs — they keep the code small enough to hold in your head.
For production, they’re the starting list.