Building DBDB from Scratch — Part 6


Post 1 told you that write() acquires a lock and holds it until commit_root_address() releases it. That one-lock-per-session design keeps a writer’s blobs exclusive until the root is flipped.

But I glossed over something. The lock keeps other writers waiting. It doesn’t tell them anything about what they missed while they waited.

Imagine Process B has been sitting at the door while Process A committed. The door opens. B walks in and starts writing its new tree. But B’s mental model of the database is from before A’s commit. B thinks the root is at offset 4107. A moved it to 5200.

If B commits without checking, it overwrites A’s work. Silently. A’s key-value pairs vanish.

This is the race that the lock alone doesn’t solve. Let’s trace it.


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 (this post) 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 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

Three Ways Processes Can Interact

Readers and writers don’t block each other

This one is by design. Readers never call lock(). They refresh the root from the superblock, follow refs down the tree, and return values. The whole time, a writer might be appending blobs to the end of the file — and that’s fine, because readers only follow the old root, which points to an old but complete tree.

Process A (reader)              Process B (writer)
──────────────────              ─────────────────────────────────────
open DB → root = 4107
get("apple")                    write(blob_value)  ─── lock acquired
  read node at 4107                                    (B holds lock)
  read value at 4096            write(blob_node)
  return "red"                  commit_root(5200)  ─── lock released,
                                                       root flipped to 5200
get("apple")  ← again
  unlocked → refresh
  root = 5200  ← sees B's commit
  read node at 5200
  ...

Between A’s two reads, B committed a new tree. A’s first read came from the old snapshot — perfectly valid, because the old tree wasn’t modified. A’s second read comes from the new snapshot. Both are consistent views.

This is intentional. DBDB trades strict read isolation for simplicity: a reader might see data that’s one commit behind if it doesn’t refresh. The cost is acceptable for a single-file educational database. PostgreSQL calls a stronger version of this “repeatable read isolation”; DBDB offers something closer to “read committed on refresh.”

Two writers: the dangerous case

Process A (writer)                  Process B (writer)
──────────────────────────          ─────────────────────────────────
open DB → root = 4107
_insert("apple", "red")             open DB → root = 4107
  → new tree built in RAM           _insert("banana", "yellow")
                                      → new tree built in RAM
write(blob_value)  ── lock acquired
write(blob_node)
                                    write(blob_value)  ── BLOCKS here
                                    (B is waiting for A's lock)

commit_root(5200)  ── lock released, root = 5200

                                    ← B acquires lock now
                                    ⚠ B still thinks root = 4107
                                    write(blob_node)
                                    commit_root(5350)
                                    → root = 5350, based on 4107
                                    → "apple" is gone

Process B’s new tree was built on root 4107 — a snapshot that didn’t include A’s “apple”. B commits that tree and becomes the new root. A’s entire commit is now unreachable. Not corrupted — it’s still on disk as orphaned blobs — but effectively deleted from the database’s perspective.

The lock queued the writers correctly. It failed to tell B what it missed.

The fix: refresh when you first acquire the lock

When lock() returns True, it means “I just grabbed this for the first time” (as opposed to returning False, which means “I already had it”). That True is the signal: someone else might have committed while I was waiting.

# set() — the next post will fill this in with real code:
def set(self, key, value):
    if self._storage.lock():       # True = just acquired, not "already had it"
        self._refresh_tree_ref()   # read the current root — A may have committed
    # Now build the new tree on top of the latest committed state
    new_value_ref = self.value_ref_class(referent=value)
    self._tree_ref = self._insert(
        self._follow(self._tree_ref), key, new_value_ref
    )

After refresh, B reads root = 5200 (A’s committed tree), inserts “banana” into that tree, and commits a tree containing both “apple” and “banana”.

The key insight: lock() returning True vs False isn’t just a re-entrancy guard (though that too — see the deadlock note below). It’s a semantic signal carrying the question “did you wait for this, or did you already own it?” Only in the former case do you need to refresh.


The Flag That Connects Two Layers

LogicalBase doesn’t import Storage. Storage doesn’t know about trees. Yet they need to coordinate on one question: is there a write session in progress?

The answer lives in storage.locked — a single boolean that Storage sets and LogicalBase reads:

Storage                             LogicalBase
───────────────────────             ──────────────────────────────
self.locked = False  (init)
                                    if not self._storage.locked:     ← reads it here
lock() → self.locked = True             self._refresh_tree_ref()
write()  ← uses flag                    (safe to see latest root)
write()
commit_root()
unlock() → self.locked = False
                                    if self._storage.lock():         ← True = just acquired
                                        self._refresh_tree_ref()     ← refresh now

Storage owns the flag. LogicalBase observes it. Neither layer knows the other’s internals — storage.locked is the only shared signal between them.

This is why __len__ checks self._storage.locked before refreshing:

def __len__(self):
    if not self._storage.locked:
        self._refresh_tree_ref()
    # If we're locked, we're mid-write. Refreshing would discard
    # the in-RAM tree we've been building. Hold the snapshot.
    root = self._follow(self._tree_ref)
    return root.length if root else 0

“Am I in a write session right now?” — answered by one boolean, shared across layers.


The Nested Lock Problem (and Why It’s Already Solved)

One trap with advisory locks: if your own code calls lock() twice on the same file descriptor, you can deadlock waiting for yourself.

DBDB avoids this by the design of lock() itself:

def lock(self) -> bool:
    if not self.locked:
        portalocker.lock(self._f, portalocker.LOCK_EX)
        self.locked = True
        return True
    return False   # already locked — skip the OS call entirely

When write() calls lock(), it gets True and acquires the OS lock. When _write_integer() (called inside write()) also calls lock(), it gets False — the flag is already set, so portalocker is never called again. No second lock, no deadlock.

write()          → lock() → portalocker.lock() → locked=True → returns True
  _write_integer() → lock() →                  → locked=True → returns False (no-op)
  _write_integer() → lock() →                  → locked=True → returns False
commit_root()    → lock() →                    → locked=True → returns False
  _write_integer() → lock() →                  → locked=True → returns False
  → portalocker.unlock() → locked=False

The flag does double duty: it prevents nested OS lock calls, and it signals to LogicalBase whether a write session is in progress.


Crash in the Middle: Why Append-Only Saves You

What happens if a process crashes between write() and commit_root_address()?

Process A crashes here:
  write(blob_value)   ← blob at offset 4096
  write(blob_node)    ← blob at offset 4107
  💥 crash — commit_root never called

File on disk afterward:
  superblock: root = 4107_OLD   ← was never overwritten
  offset 4096: orphaned blob
  offset 4107: orphaned blob

The root was never flipped. Any process that opens the file afterward reads the old root — a complete, consistent tree from before the crash. The two orphaned blobs are stranded on disk with no pointers to them. They waste space, but they don’t corrupt anything.

This is append-only paying its rent. In an in-place update database, a crash mid-write can leave a data structure in an inconsistent state — half of a node updated, the other half not. That’s why those systems need write-ahead logs and crash recovery procedures.

DBDB doesn’t need any of that. The old tree is always intact. “Recovery” is just “open the file and read the root” — which is what you do anyway.

The cost: the orphaned blobs are never reclaimed. Every crashed write leaks a little space. Every successful write also leaves old blobs behind (the old version of the tree). This is the space amplification tradeoff that real databases address with compaction — DBDB doesn’t implement it, but the shape of the problem is clear.


What This Post Leaves Open

The two-writer scenario is described, but the fix — refreshing on lock acquisition — lives in set() and pop(), which haven’t been built yet. The current LogicalBase has the mechanism (storage.locked, _refresh_tree_ref) but not the wiring.

The next post closes the loop: get, set, pop, and commit land in LogicalBase, and the locking story told here finally has its last piece in place — a real set() that acquires the lock, refreshes when needed, and defers commit to the caller.