Frontend Labs
⋯

56. Capstone: a tiny embedded database engine (Bitcask-style)

Goal: the expert-tier capstone — an append-only log-structured key-value store with an in-memory index, the same core design as Bitcask/early LevelDB, built with nothing but the standard library. This pulls together file I/O (Ch. 22), binary encoding (Ch. 34.4), generics (Ch. 21/26), and sync (Ch. 17).

56.1 The design

Writes always append to a single log file (data.log) — never seek-and-overwrite — which makes writes fast (sequential disk I/O) and crash-safe (a half-written record at the tail is simply the last thing to recover, never corrupting earlier data). An in-memory map indexes each key to the byte offset of its most recent record in the log, so reads are one seek plus one read, not a scan.

package bitcask

import (
	"encoding/binary"
	"errors"
	"io"
	"os"
	"sync"
)

var ErrNotFound = errors.New("key not found")

type entryHeader struct {
	KeyLen   uint32
	ValueLen uint32
	Tombstone uint8 // 1 = this record represents a delete
}

const headerSize = 4 + 4 + 1

type indexEntry struct {
	offset int64
	size   int64
}

type DB struct {
	mu    sync.RWMutex
	file  *os.File
	index map[string]indexEntry
}

func Open(path string) (*DB, error) {
	f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o644)
	if err != nil {
		return nil, err
	}
	db := &DB{file: f, index: map[string]indexEntry{}}
	if err := db.rebuildIndex(); err != nil {
		_ = f.Close()
		return nil, err
	}
	return db, nil
}

func (db *DB) rebuildIndex() error {
	if _, err := db.file.Seek(0, io.SeekStart); err != nil {
		return err
	}
	var offset int64
	for {
		header := make([]byte, headerSize)
		if _, err := io.ReadFull(db.file, header); err != nil {
			if err == io.EOF || err == io.ErrUnexpectedEOF {
				break
			}
			return err
		}
		keyLen := binary.BigEndian.Uint32(header[0:4])
		valLen := binary.BigEndian.Uint32(header[4:8])
		tombstone := header[8]

		key := make([]byte, keyLen)
		if _, err := io.ReadFull(db.file, key); err != nil {
			return err
		}
		recordSize := int64(headerSize) + int64(keyLen) + int64(valLen)

		if tombstone == 1 {
			delete(db.index, string(key))
			if _, err := db.file.Seek(int64(valLen), io.SeekCurrent); err != nil {
				return err
			}
		} else {
			db.index[string(key)] = indexEntry{
				offset: offset + int64(headerSize) + int64(keyLen),
				size:   int64(valLen),
			}
			if _, err := db.file.Seek(int64(valLen), io.SeekCurrent); err != nil {
				return err
			}
		}
		offset += recordSize
	}
	return nil
}

func (db *DB) Put(key, value []byte) error {
	db.mu.Lock()
	defer db.mu.Unlock()

	offset, err := db.file.Seek(0, io.SeekEnd)
	if err != nil {
		return err
	}
	if err := writeRecord(db.file, key, value, 0); err != nil {
		return err
	}
	db.index[string(key)] = indexEntry{
		offset: offset + int64(headerSize) + int64(len(key)),
		size:   int64(len(value)),
	}
	return nil
}

func (db *DB) Get(key []byte) ([]byte, error) {
	db.mu.RLock()
	entry, ok := db.index[string(key)]
	db.mu.RUnlock()
	if !ok {
		return nil, ErrNotFound
	}

	db.mu.RLock()
	defer db.mu.RUnlock()
	value := make([]byte, entry.size)
	if _, err := db.file.ReadAt(value, entry.offset); err != nil {
		return nil, err
	}
	return value, nil
}

func (db *DB) Delete(key []byte) error {
	db.mu.Lock()
	defer db.mu.Unlock()
	if _, err := db.file.Seek(0, io.SeekEnd); err != nil {
		return err
	}
	if err := writeRecord(db.file, key, nil, 1); err != nil {
		return err
	}
	delete(db.index, string(key))
	return nil
}

func writeRecord(w io.Writer, key, value []byte, tombstone uint8) error {
	header := make([]byte, headerSize)
	binary.BigEndian.PutUint32(header[0:4], uint32(len(key)))
	binary.BigEndian.PutUint32(header[4:8], uint32(len(value)))
	header[8] = tombstone
	if _, err := w.Write(header); err != nil {
		return err
	}
	if _, err := w.Write(key); err != nil {
		return err
	}
	_, err := w.Write(value)
	return err
}

func (db *DB) Close() error {
	db.mu.Lock()
	defer db.mu.Unlock()
	return db.file.Close()
}

56.2 What this teaches, and where it stops being a toy

This gets you: O(1) writes (always append), O(1) reads (one map lookup + one ReadAt, no seek-and-scan), and crash recovery by replaying the log on startup. What it deliberately leaves out, and what real engines (LevelDB, RocksDB, Bitcask itself) add on top: compaction (the log grows forever otherwise — old, overwritten/deleted records are never reclaimed here), concurrent readers during compaction, checksums per record (to detect a torn write after an unclean shutdown, not just truncate at EOF), and a write-ahead-log + separate index snapshot to avoid replaying the entire log on every restart. Each of those is a natural next exercise once this base version passes its own tests.

func TestPutGetDelete(t *testing.T) {
	dir := t.TempDir()
	db, err := Open(dir + "/data.log")
	if err != nil {
		t.Fatal(err)
	}
	defer func() {
		if err := db.Close(); err != nil {
			t.Fatal(err)
		}
	}()

	if err := db.Put([]byte("k1"), []byte("v1")); err != nil {
		t.Fatal(err)
	}
	v, err := db.Get([]byte("k1"))
	if err != nil || string(v) != "v1" {
		t.Fatalf("got (%s, %v)", v, err)
	}

	if err := db.Delete([]byte("k1")); err != nil {
		t.Fatal(err)
	}
	if _, err := db.Get([]byte("k1")); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

func TestRecoversIndexOnReopen(t *testing.T) {
	dir := t.TempDir()
	path := dir + "/data.log"
	db, err := Open(path)
	if err != nil {
		t.Fatal(err)
	}
	if err := db.Put([]byte("k1"), []byte("v1")); err != nil {
		t.Fatal(err)
	}
	if err := db.Close(); err != nil {
		t.Fatal(err)
	}

	reopened, err := Open(path)
	if err != nil {
		t.Fatal(err)
	}
	defer func() {
		if err := reopened.Close(); err != nil {
			t.Fatal(err)
		}
	}()
	v, err := reopened.Get([]byte("k1"))
	if err != nil || string(v) != "v1" {
		t.Fatalf("index not rebuilt correctly: got (%s, %v)", v, err)
	}
}

Generated from real-life-golang-guide.md · 8 Oct 2026 16:36