SD System Design In Depth
0%
lesson system-designstorage-engines

A file-backed dictionary

Module 2.3: Storage Engine Internals & Blob Systems Updated Sep 17, 2026
📄 Cheat Sheet
Chapter 3.1 - Append only log and hash indexes (Storage and retrieval)
Rahul Raja Chapter 3.1 - Append only log and hash indexes (Storage and retrieval) Watch on YouTube ↗

A clear chapter walk-through of DDIA's append-only log plus in-memory hash index, covering segments, tombstones, compaction and crash recovery exactly as this unit does.

0:00 / --:--

A file-backed dictionary

Suppose we need an API that returns a word's meaning. Definitions change in a weekly batch, reads dominate, and the exercise excludes a database. The data file may be large, but the words and their locations can fit in memory.

We'll build the read path around that difference, then work through publishing an update. Several API servers should be able to read the same completed dictionary without each holding every definition in RAM.

What belongs on each server?

The workload leaves us several choices:

ArrangementCost or limitation for this dictionary
Load the complete dictionarySimple lookups, but every server needs RAM for all values
One file or object per wordIndependent updates, but many artifacts to publish and manage
Scan one large file per lookupSimple format, but search work grows with the file
Mutable B-treeSupports ordered updates; more machinery than a weekly rebuild needs
Memory index and immutable data fileSmall lookup structure, with an extra read for the value
Choose the last option while the full index fits memory and batch rebuilds are affordable. Keep the data file locally if replicated disk space and distribution are acceptable; object storage can instead hold a shared copy and serve selected byte ranges.

A word points to bytes

Build the file and index together. As the builder writes each UTF-8 meaning, it records the starting byte offset and encoded length. This tiny uncompressed example packs two meanings without separators:

index for dictionary-v1:
  apple  -> offset 0, length 5
  banana -> offset 5, length 12

data bytes: fruityellow fruit

The lengths separate fruit from yellow fruit. A server loads this version's index at startup. For banana it finds (5, 12), then reads those bytes from dictionary-v1. If the key is absent from the complete index, it returns absence without reading the data file.

sequenceDiagram accTitle: A lookup in dictionary v1 accDescr: The API uses its loaded v1 index to locate banana, then reads the matching byte range from the immutable v1 object and returns the meaning. participant User participant API participant Index as Loaded v1 index participant Data as Dictionary v1 object User->>API: Meaning of banana API->>Index: Find banana Index-->>API: Offset 5, length 12 API->>Data: Range bytes 5-16 Data-->>API: yellow fruit, 12 bytes API-->>User: yellow fruit

That successful uncached lookup uses one memory lookup and one value request, after loading the index. It is not a promise of one physical disk read or fixed latency. Validate the returned version, range and length; the byte-range lesson handles the HTTP details.

Budget the index from key bytes, offsets, lengths and the map's own overhead. At an assumed 40 bytes per complete in-memory entry, 500,000 words would need 20 MB. Measure the actual representation: object headers and spare hash-table capacity can make a compact serialized index much larger after loading. Replicating that index across API servers is the cost of keeping lookup local.

Rebuild from the weekly changes

Sort the old dictionary and the week's changes by the same key ordering. Resolve multiple changes to one word before the merge, using an explicit update order. Then walk both sorted inputs:

  • An old-only word keeps its definition.
  • A changed word uses the new definition, or is omitted if the change deletes it.
  • A new-only word is inserted.

Write dictionary-v2 and its index during this pass. New offsets come from the bytes actually emitted; changing apple's length moves later definitions. A streaming merge avoids loading all values together, though producing sorted inputs may itself require external sorting.

Validate the completed pair, then publish a version selector naming both. Readers load the new index and switch the file identity with it. Retain v1 while any reader still uses its index. Replacing v1's bytes beneath those offsets could return another word's text without causing a parse error.

Practice publishing a complete candidate

Before implementing the remote design, the storage-engine download exercises a smaller local snapshot. It stores the whole two-word map as JSON, so this version loads values into memory and has no offset index.

Its helper writes a candidate in the same directory, flushes Python's buffer, calls os.fsync, replaces the published filename with os.replace, then synchronizes the directory. A successful same-filesystem rename provides an atomic name change; durable publication also depends on the synchronization succeeding and the filesystem's guarantees. Errors propagate.

Snapshot mode writes an intentionally truncated candidate before publishing a complete replacement:

python3 m12-storage-engine.py snapshot
snapshot bytes: 41
reopened: {"apple":"fruit","banana":"yellow fruit"}
truncated candidate rejected; published apple: fruit
replacement bytes: 45 ; reopened apple: red fruit

The old published dictionary survives rejection of the incomplete candidate. The complete replacement adds four encoded bytes. These sizes count compact JSON punctuation too; they are unrelated to the 17-byte raw value pack above.

The helper writes generated JSON. An importer also needs to validate the expected string-map shape and input limits before publication. A valid JSON array is not a dictionary. A malformed published file is a storage error, not an empty dictionary or a missing word.

A reader already holding the old map can keep answering from it while a new reader loads the replacement. Our weekly publication allows that overlap; instant visibility on every server would need a stronger refresh contract. This local truncation-and-reopen experiment does not test object storage or power loss.

The next lesson puts explicit boundaries into the stored bytes. Versioned files then keep those bytes and their readers together during replacement and cleanup.


Related design: an append-only mutable dictionary

One simple dictionary design publishes immutable snapshots that are rebuilt periodically (for example weekly). When updates must arrive continuously, a different design is an append-only log paired with an in-memory hash table, as in Bitcask. This variant trades cheap incremental writes for recovery and compaction work; it complements rather than replaces a snapshot-publication design.

The Architecture of an Append-Only Dictionary

flowchart LR subgraph MemoryRAM ["DRAM: In-Memory Index (Hash Map)"] K1["'user:101' -> Offset: 0, Size: 84"] K2["'user:102' -> Offset: 84, Size: 92"] K1U["'user:101' -> Offset: 176, Size: 88 (Updated)"] end subgraph DiskData ["Persistent Storage: Append-Only Data File"] R1["[0..83] Record 1: key='user:101', val='Alice'"] R2["[84..175] Record 2: key='user:102', val='Bob'"] R3["[176..263] Record 3: key='user:101', val='Alice V2' (Append)"] end K1U -.->|"Seek(176), Read(88)"| R3

Internal Mechanics & Step-by-Step Operations

1. The Write Operation (Set Key-Value)

To insert or update a key:

  1. Encode the key and value into a binary record structure containing timestamp, key length, value length, key bytes, and value payload.
  2. Append the record to the end of the active data file using write(fd, buffer, len). Record the byte offset where the write began.
  3. Update the in-memory hash map: index[key] = { file_id, offset, size, timestamp }.
  4. Because writes are strictly append-only, disk head movement is minimized and write throughput reaches hundreds of megabytes per second.

2. The Read Operation (Get Key)

To read a key: Query the in-memory hash map. If the key exists, retrieve the file descriptor, byte offset, and record length. Execute pread(fd, buf, size, offset). Verify checksum and extract payload. Requires at most one logical range read (one seek on a cold HDD; the page cache may serve it with no disk I/O at all).

3. The Delete Operation (Tombstone)

Deletions cannot modify existing bytes in an append-only log. Instead, append a special record called a Tombstone (a record with value size set to -1 or a tombstone flag bit). Remove the key from the in-memory hash index.

Worked example: sizing a log plus hash index

Store 10 million keys, average key 20 bytes, average value 1 KB.

  • Data on disk: about 10 million x (1 KB value + 20 B key + ~20 B header) which is roughly 10.4 GB for one live copy. With updates, dead versions accumulate until compaction.
  • Index in RAM: each entry holds the key plus file id, offset, length and timestamp (about 24 bytes), and a hash map adds pointer and bucket overhead. Budget roughly 80-100 bytes per key, so about 0.8-1 GB of RAM for 10 million keys.
  • Cold start: rebuilding the index by scanning 10.4 GB at 1 GB/s takes about 10 seconds; a compact hint file holding only keys and offsets (about 0.5 GB here) shrinks that to under a second of sequential reading.

Write, read and recover, step by step

  1. Write put("user:42", v): encode crc | timestamp | key_len | value_len | key | value, append at offset 7,340,032 in data-0005, then set index["user:42"] = (5, 7340032, len).
  2. Read: look up the index, issue one pread at that offset, verify the CRC, return the value.
  3. Crash: on restart, scan segments oldest to newest; later records overwrite earlier index entries; tombstones remove keys; a final record whose CRC fails is the torn tail and is truncated.

Design comparison

DesignWrite costPoint readRange scanRAM need
Immutable snapshot (rebuild weekly)Whole rebuildOne read with an offset indexGood if sortedSmall (sparse index)
Append-only log plus hash indexOne sequential appendOne readNot supportedAll keys in RAM
B-tree filePage rewrite plus WALFew page readsExcellentOnly cached pages

Real systems: Bitcask (Riak) is exactly this design; Kafka uses append-only segments with a sparse offset index; many embedded caches use it because writes never seek.

Check yourself Question 1 of 3
Deleting in a log recall
From: Internal Mechanics & Step-by-Step Operations

In an append-only log with an in-memory hash index, how must a delete be implemented so that it survives a restart?

Key Takeaways

An append-only log with an in-memory hash index can locate a value with one logical range read; caches, filesystems, and devices determine the physical I/O.
Updates never overwrite previous data; they append new versions and update the in-memory byte offset pointer.
Deletions are recorded as append-only tombstone records that invalidate previous entries during compaction.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue