Skip to content

🧠 In-Memory KV Store — Thought Process

📊 Class Diagram

classDiagram
    class Store~V~ {
        -shards []shard~V~
        -seed maphash.Seed
        -cost func key, v to int64
        -now func to Time
        -version atomic.Uint64
        -watchMu sync.RWMutex
        -watchers set of watcher~V~
        -hits, misses, evictions, expirations, dropped atomic.Int64
        +Set(key string, v V, ttl Duration) uint64, error
        +Get(key string) V, bool
        +GetVersioned(key string) V, uint64, bool
        +Delete(key string) bool
        +CompareAndSwap(key string, expected uint64, v V, ttl Duration) uint64, error
        +Expire(key string, ttl Duration) bool
        +Persist(key string) bool
        +TTL(key string) Duration, bool
        +Keys(prefix string) []string
        +DeleteExpired() int
        +RunJanitor(ctx Context, interval Duration) error
        +Watch(ctx Context, prefix string) chan Event~V~
        +Snapshot(w io.Writer) error
        +Restore(r io.Reader) error
        +Stats() Stats
        -putLocked(sh shard~V~, key string, v V, expiresAt Time, version uint64, now Time) error
        -publish(ev Event~V~)
    }
    class Options~V~ {
        +Shards int
        +MaxCost int64
        +Cost func key, v to int64
        +NewPolicy func EvictionPolicy
        +Clock func Time
        +WatchBuffer int
    }
    class shard~V~ {
        -mu sync.Mutex
        -items map string to entry~V~
        -policy EvictionPolicy
        -expiry expiryHeap~V~
        -cost int64
        -maxCost int64
    }
    class entry~V~ {
        -key string
        -value V
        -version uint64
        -expiresAt Time
        -cost int64
        -heapIdx int
        -expired(now Time) bool
    }
    class expiryHeap~V~ {
        +Len() int
        +Less(i int, j int) bool
        +Swap(i int, j int)
        +Push(x any)
        +Pop() any
    }
    class EvictionPolicy {
        <<interface>>
        +OnInsert(key string)
        +OnAccess(key string)
        +OnRemove(key string)
        +Victim() string, bool
        +Name() string
    }
    class LRU {
        -ll list.List
        -idx map string to Element
    }
    class LFU {
        -idx map string to Element
        -buckets map freq to list.List
        -minFreq int
    }
    class watcher~V~ {
        -prefix string
        -ch chan Event~V~
    }
    class Event~V~ {
        +Type EventType
        +Key string
        +Value V
        +Version uint64
    }
    class EventType {
        <<enumeration>>
        EventSet
        EventDelete
        EventExpire
        EventEvict
    }
    class Stats {
        +Hits int64
        +Misses int64
        +Evictions int64
        +Expirations int64
        +DroppedEvents int64
        +Items int
        +Cost int64
    }

    Store ..> Options : NewStore(opts)
    Store "1" *-- "N" shard : hash-sharded
    Store "1" o-- "*" watcher : Watch
    Store ..> Stats : Stats()
    shard "1" *-- "*" entry : items
    shard *-- expiryHeap : TTL index
    expiryHeap o-- entry : heapIdx
    shard --> EvictionPolicy : policy
    LRU ..|> EvictionPolicy
    LFU ..|> EvictionPolicy
    Options ..> EvictionPolicy : NewPolicy
    watcher --> Event : ch
    Event --> EventType

Problem Breakdown

Step 1: Core Data Structure

  • A map gives O(1) get/set/delete.
  • Make values generic (Store[V any]) so callers get typed values back and snapshots round-trip without type loss.
  • Track a per-entry cost. The default is 1 per entry, so capacity means "max items". Plug in a byte-based cost function if the interviewer wants a memory limit.

Step 2: Concurrency

  • First instinct: sync.RWMutex. Then notice that LRU makes every read a write, because Get moves the key in the recency list. An RWMutex buys nothing and invites a race.
  • So: shard the keyspace. Each shard has its own sync.Mutex, map, policy and heap. Contention drops by roughly N.
  • Trade-off to say out loud: per-shard eviction approximates global LRU. One shard gives exact LRU.

Step 3: Eviction Policies

  • Strategy interface: OnInsert, OnAccess, OnRemove, Victim. Policies are not thread-safe. The shard lock guards them.
  • LRU: container/list plus a map, O(1).
  • LFU: frequency buckets plus minFreq, O(1). A heap would be O(log n) and needs decrease-key handling.

Step 4: TTL

  • Lazy expiry on every access, plus an active janitor so keys nobody reads still free their memory.
  • Indexed min-heap per shard, so overwrite and Persist can remove the old deadline in O(log n).
  • Inject the clock so tests don't time.Sleep.

Step 5: CAS, Watch, Snapshot

  • Versions come from a store-wide atomic counter. Per-key counters reset on re-create and cause ABA.
  • Watch(ctx, prefix) <-chan Event: the store owns and closes the channel, and cancelling ctx unsubscribes. Sends are non-blocking, so a slow watcher can't stall writers.
  • Snapshot to an io.Writer as JSON. Restore skips anything that expired in the meantime.

Key Decisions

Decision Why
Sharded sync.Mutex over a global RWMutex LRU/LFU mutate on read; striping is what actually reduces contention
Policies not goroutine-safe One lock per operation; no lock-ordering bugs
Replace = remove + insert A write can never evict itself; cost accounting stays exact
Indexed expiry heap Bounded heap size; O(log n) TTL changes
Lazy + active expiry Reads never see dead keys; unread keys still get reclaimed
Global monotonic version CAS is ABA-safe across delete/re-create and restarts
Non-blocking watch fan-out Writers' latency is independent of the slowest consumer

⏱️ How to run this in a 45–60 min interview

Time Phase What to do What to say out loud
0–5 Clarify Ask the questions below and write the API on the board "I'll assume a single process, in-memory, item-count capacity, and that I can extend to bytes."
5–10 Entities Store, entry, EvictionPolicy, expiry heap "Eviction is the obvious axis of change, so it's an interface from the start."
10–25 Core Map + LRU + Set/Get/Delete with capacity "Replace removes first, so a write never evicts itself."
25–35 TTL Lazy check in Get, heap, DeleteExpired "Lazy expiry alone leaks memory for keys nobody reads, so I add a janitor."
35–45 Concurrency One mutex, then shards "RWMutex is a trap here, because LRU writes on read."
45–60 Extension CAS, Watch or LFU, whichever they ask for Show that the change touches one place.

Clarifying questions worth asking

  • Is capacity a number of items or a number of bytes? What happens to a single value bigger than capacity?
  • Do we need TTL? Must expiry be exact, or only exact as far as readers can see?
  • What's the read/write ratio, and how many concurrent clients?
  • Which eviction policy: LRU, LFU, or pluggable?
  • Do we need durability across restarts, or is it a cache?
  • Do we need conditional writes (CAS / SETNX) or change notifications?