Brief
Build a single-file, single-table database engine with an interactive shell, in any
language (C follows the original most closely; the formats below are
language-neutral). The table is fixed-schema — id INTEGER PRIMARY KEY, username VARCHAR(32), email VARCHAR(255) — which keeps parsing trivial and puts all the
difficulty where it belongs: pages, trees, and durability. By the end, rows survive
kill -9, lookups are logarithmic, and you can explain every byte in the file.
Instructions
1. REPL and grammar
An interactive prompt (db > ) accepting:
- Meta-commands, prefixed with a dot:
.exit; add.btree(print the tree) and.constants(print layout constants) as you go — they are your debugger. - Statements:
insert <id> <username> <email>— id is a positive integer; strings are space-delimited (no quoting needed).select— print every row as(id, username, email)in key order.- Later:
select <id>(point lookup) andselect where email = <value>(via the secondary index).
- Unrecognized input gets a specific error (
Unrecognized keyword at start of '…'), oversized strings and negative ids are rejected at parse time — the statement is validated before it executes.
2. Row format and in-memory pages
- A serialized row is fixed-size:
idu32 little-endian,username32 bytes,email255 bytes, null-padded — offsets are compile-time constants. - Storage is organized in 4096-byte pages held in a page array; rows pack
contiguously. First version: append-only, full-scan
select, memory only.
3. The pager
- All page access goes through a pager:
get_page(page_num)returns a cached page, reading it from the database file on first touch (a page-sized cache slot per page; no eviction needed at this scale). - On
.exit, flush dirty pages to the file atpage_num * 4096. Reopening the file finds the data: file length / page size tells you what exists.
4. B-tree leaf nodes
Replace the packed array with one page = one B-tree node. Common node header: node-type byte, is-root byte, parent pointer (u32). Leaf body: cell count (u32), next-leaf pointer (u32, 0 = none, for range scans), then cells — each cell a key (u32) + serialized row, kept sorted by key.
- Insert finds the position by binary search; duplicate keys are an error
(
Error: Duplicate key.). - When a leaf overflows, split: allocate a sibling, move the upper half of the cells, wire the next-leaf pointers, push the split key to the parent.
5. Internal nodes and the full tree
Internal body: key count (u32), rightmost-child pointer (u32), then pairs of (child pointer u32, max key in that child u32).
- Root split: the root's contents move to a fresh page; the root page itself becomes the new root (internal node with two children) so the root's page number never changes (page 0 forever).
- Point lookups and inserts descend by binary search over the keys;
selectwalks the leftmost leaf then follows next-leaf pointers. - Internal-node splitting must work too — inserting thousands of keys in random
order may not corrupt the tree (
.btreeoutput stays consistent).
6. Write-ahead log
Durability, past the tutorial's edge:
- Record format (yours to refine, but at least): record type (page-write/commit), page number, page image (or a delta), and a checksum (CRC32 over the record) so a torn tail is detectable.
- Every mutation appends its record(s) plus a commit record to the WAL and fsyncs the WAL before acknowledging the insert; only then may the pager write the page in place (or defer it).
- On open: scan the WAL, discard anything after the last valid-checksummed commit, replay the rest onto the data file, fsync, then truncate.
- Checkpoint: when the WAL exceeds a threshold (or on a
.checkpointmeta-command), flush all replayed/dirty state to the main file, fsync it, truncate the WAL. - Write the crash-test harness: a script that spawns the REPL feeding it inserts,
kill -9s it at random points in a loop, reopens, and verifies every acknowledged row is present and the tree is well-formed.
7. Secondary index
- A second B-tree (same node formats) keyed on
email(fixed 255-byte key or a hash of it — decide and document), whose values are row ids (the primary key). - Maintained in the same logical write as the primary insert — WAL covers both, so a crash can't leave them out of sync.
select where email = …descends the index then the primary tree.- Benchmark: N random inserts and M point reads by email, with and without the index — show the read speedup and measure the write tax.
Examples
db > insert 1 user1 person1@example.com
Executed.
db > insert 2 user2 person2@example.com
Executed.
db > select
(1, user1, person1@example.com)
(2, user2, person2@example.com)
Executed.
db > insert 1 dup dup@example.com
Error: Duplicate key.
db > .exit
Constraints
- No storage, B-tree, or serialization libraries — stdlib file I/O and memory only. (Testing libraries and a CRC implementation are fine.)
- Page size stays 4096 bytes everywhere; all multi-byte integers little-endian.
- Every stage keeps the previous stage's tests passing; keep a growing test file of REPL sessions (script in / expected out).
Acceptance
Mapped to the exercise's milestones:
- REPL — the session in Examples behaves exactly as shown for the happy paths; bad input (unknown keyword, string too long, negative id) yields the specific error and a clean next prompt.
- In-memory table — insert then select round-trips rows; a row exactly filling the last page slot works; inserting past the page-array cap reports a full table rather than corrupting.
- Pager — insert,
.exit, reopen,select: rows are back. Hexdump the file and point at a row's bytes. - Leaf nodes — out-of-order inserts come back sorted; duplicate key errors;
overflowing one leaf produces a split visible in
.btree. - Full tree — 10k random-order inserts:
selectreturns all in order,select <id>touches only ~log-depth pages (instrument the pager to count),.btreeshows a balanced multi-level tree. - WAL — the crash-test harness runs ≥100 kill-reopen cycles with zero acknowledged-row loss and zero malformed trees; a WAL with a torn final record opens cleanly, dropping only the unacknowledged tail.
- Secondary index — email lookups return the same rows as a full scan; crash-testing during indexed inserts never de-syncs the two trees; the benchmark table (reads/sec, writes/sec, with/without index) is committed with the code.
Related
- Build your own database — the exercise note this is the subject of.
- Let's Build a Simple Database (cstack) — the tutorial stages 1–5 condense; formats above stay compatible with its spirit, not its exact bytes.
- build-your-own.org — database book — the Go-flavored alternative source.