Day 1: Design the Key-Value API via TDD

Lesson 1 60 min

Day 1: Design the Key-Value API via TDD — and Ship a Memory-Backed Store

Distributed databases like Spanner, Cassandra, and DynamoDB do not begin their lives as complex multi-region engines. They start as highly optimized, single-node storage engines. Before we can introduce the complexities of write-ahead logging, disk serialization, or consensus-driven replication, we must establish a rock-solid, thread-safe memory contract.

Today, we will design and build the core in-memory engine of our key-value store using Test-Driven Development (TDD). We will establish the API contract, enforce memory isolation, and prove thread safety under high concurrent load.

The Production Stakes: The Reappearing Data Bug

In 2012, operators of Apache Cassandra clusters noticed a terrifying behavior: data that had been explicitly deleted would suddenly reappear hours or days later. This class of bug, often called the "Ghost Record" or "Zombie Row" anomaly, cost engineering teams weeks of diagnostic time.

The root cause was an architectural mismatch between the API contract and the internal storage state machine. When a delete request arrived, the engine did not immediately purge the record (which would require expensive disk random writes). Instead, it wrote a marker called a tombstone. If the API did not strictly isolate read/write boundaries, or if concurrent operations bypassed the lock boundaries, old data from lagging replicas or memory buffers could overwrite the tombstone, resurrecting the deleted record.

By designing our API via TDD, we force ourselves to define the exact state transitions of writes, reads, and deletes before a single line of storage logic is written. This prevents the API boundary leaks that lead to production data corruption.

Lock Contention and Memory Isolation

To build an in-memory key-value store in a concurrent language like Go, we must manage two physical realities: race conditions and pointer sharing.

The Lock Contention Math

If multiple execution threads read and write to a shared map simultaneously, the runtime will panic. To prevent this, we use a Read-Write Mutex (sync.RWMutex).

  • A write operation requires an exclusive lock, blocking all other readers and writers.

  • A read operation requires a shared lock, allowing other readers to proceed but blocking writers.

At 100 nanoseconds per lock acquisition, a single global lock theoretically caps our throughput at 10 million operations per second. In reality, thread context switching and cache invalidation lower this limit significantly. Under high write contention, threads spend more time waiting for the lock than doing actual work.

The Pointer Leak Vulnerability

In Go, slice headers ([]byte) are reference types. They contain a pointer to an underlying array. If you pass a slice to a store, and then modify that slice later in your application code, you mutate the database's internal state without holding a lock. This bypasses our concurrency controls and corrupts data silently.

To prevent this, our engine must perform defensive copying. Every write must copy the incoming byte slice into a new memory allocation inside the store. Every read must copy the internal byte slice before returning it to the caller.

Component Architecture

HTTP CLIENT POST /keys/{key} GET /keys/{key} JSON/Raw KVSTORE ENGINE HTTP Router Request Parsing Isolation Boundary make([]byte, len(src)) copy(dest, src) Safe Copy MEMORY STORE sync.RWMutex Thread Protection map[string][]byte Key-Value Map

The Architecture of the In-Memory Store

Our architecture consists of three core components:

  1. The API Interface (KVStore): Defines the strict boundary of our storage engine.

  2. The Memory Engine (MemoryStore): Implements the interface using a hash map protected by a Read-Write Mutex.

  3. The Isolation Layer: Handles the defensive copying of byte slices during read and write paths.

    go
    // The API Contract we must satisfy
    type KVStore interface {
        Put(key string, value []byte)
        Get(key string) ([]byte, error)
        Delete(key string) error
    }
    

The sequence of a write operation is simple but strict:

  1. Acquire the exclusive write lock.

  2. Allocate a new byte slice of identical size.

  3. Copy the input data into the new slice.

  4. Insert the key and the new slice into the map.

  5. Release the lock.

Flowchart

Put(k, v) Acquire Lock mu.Lock() Defensive Copy Allocate & Copy Commit to Map data[key] = copy defer mu.Unlock()

Memory Footprint Arithmetic

When planning memory capacity, never trust raw data sizes. A Go map is not a flat array; it is a collection of buckets, each holding up to 8 key-value pairs.

  • Every string key requires a 16-byte header plus the string data.

  • Every slice value requires a 24-byte header plus the capacity of the allocated array.

  • Map overhead adds roughly 48 bytes of metadata per entry.

If you store 1,000,000 keys (16-byte UUIDs) with 100-byte values, the raw data size is:
$$text{Raw Data} = 1,000,000 times (16 + 100) text{ bytes} approx 116 text{ MB}$$

However, due to map bucket allocation and slice headers, the actual heap usage will be closer to 200 MB. Always apply a safety factor of 1.8x to 2x when sizing in-memory databases.

Designing the State Transitions

State Machine

UNALLOCATED Key Absent Put(Key, Val) Lock + Copy Alloc ALLOCATED Isolated Memory Overwrite Delete(Key) Lock + Map Purge

A key's life cycle has three valid states:

  1. Unallocated: The key does not exist.

  2. Allocated: The key points to an isolated, immutable byte array.

  3. Deleted: The key has been purged, and its memory is marked for garbage collection.

Our unit tests will assert that transitions between these states are atomic and predictable.


Assignment: Implement Conditional Put (Compare-And-Swap)

To extend our storage engine beyond basic CRUD operations, you must implement a fundamental distributed systems primitive: Compare-And-Swap (CAS), also known as PutIfMatch.

In distributed consensus and distributed locking, you must be able to update a key only if its current value matches an expected value. This prevents clients from overwriting each other's concurrent updates (lost update anomaly).

Requirements

  1. Add a new method to the KVStore interface:

    go
    CompareAndSwap(key string, expectedValue []byte, newValue []byte) (bool, error)
    
  2. If the current value of key matches expectedValue (byte-for-byte), update it to newValue and return true, nil.

  3. If the key does not exist and expectedValue is nil or empty, set the value to newValue and return true, nil.

  4. If the key exists but does not match expectedValue, make no changes and return false, nil.

  5. If the key does not exist and expectedValue is non-nil, return false, ErrKeyNotFound.

  6. Write a comprehensive unit test named TestCompareAndSwap inside store_test.go that verifies all these transitions.

Hints

  • Use bytes.Equal(a, b) to compare byte slices safely.

  • Remember to apply defensive copying to newValue before inserting it into the map!

  • Ensure you hold the exclusive write lock (mu.Lock()) for the entire duration of the read-compare-write sequence to make the operation atomic.


Solution Walkthrough

To complete the assignment, follow these architectural steps:

  1. Update the Interface: Add the method signature to KVStore in store.go.

  2. Implement in MemoryStore:

    go
    func (s *MemoryStore) CompareAndSwap(key string, expectedValue []byte, newValue []byte) (bool, error) {
        s.mu.Lock()
        defer s.mu.Unlock()
    
        current, exists := s.data[key]
        if !exists {
            if len(expectedValue) == 0 {
                // Key doesn't exist, and we expect nothing: write new value
                valCopy := make([]byte, len(newValue))
                copy(valCopy, newValue)
                s.data[key] = valCopy
                return true, nil
            }
            return false, ErrKeyNotFound
        }
    
        if !bytes.Equal(current, expectedValue) {
            return false, nil
        }
    
        // Values match: perform defensive copy and update
        valCopy := make([]byte, len(newValue))
        copy(valCopy, newValue)
        s.data[key] = valCopy
        return true, nil
    }
    
  3. Verify with Tests: Create a test case that sets a key, attempts a CAS with a mismatched expected value (verifying it returns false), and then executes a CAS with the correct expected value (verifying it updates successfully).

In the next lesson, we will make this memory-backed store durable by layering a Write-Ahead Log (WAL) on top of it, ensuring our data survives sudden process terminations.

Questions & Discussion

Leave a Reply

Your email address will not be published. Required fields are marked *