Initial commit: Go SRE interview exercises

Includes solved 02-worker-pool and 09-safe-counter (mutex, atomic, sharded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
This commit is contained in:
2026-08-28 22:18:09 +02:00
co-authored by Claude Fable 5
commit 7219eb5a23
43 changed files with 2865 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# 05 — LRU Cache
The classic "two data structures in a trench coat" problem, and the eviction
policy conversation behind it is genuinely SRE-relevant.
## The shape: map + doubly linked list
- `map[key]*list.Element` → O(1) lookup
- `container/list` (doubly linked) → O(1) move-to-front and remove-from-back
The one detail that separates working from broken: **the list element must
carry the key**, because eviction walks *from the list* (`ll.Back()`) and then
has to delete *from the map* — without the key stored in the entry you can't
close that loop. Every first attempt hits this.
Also know `container/list` is `any`-typed (predates generics) so you type-assert
`el.Value.(*entry)`. Interviewers accept a hand-rolled `prev/next` struct too —
sometimes they prefer it, since `container/list`'s API is obscure enough that
fumbling it looks worse than writing your own 20-liner.
## Why one plain Mutex (and not RWMutex)
Tempting: `RWMutex`, reads in parallel. Broken: **an LRU Get is a write** — it
mutates the recency list. Take the read lock and call `MoveToFront` and you
have a data race on the list pointers. So: plain `Mutex`, or the honest answer
"reads mutate metadata, so RWMutex buys nothing here." Saying that sentence
unprompted is a strong signal. (Sharded locks are the real scaling answer —
16 shards by key hash, like every serious in-process cache.)
## Eviction-policy nerd corner (great interview tangent)
- **LRU's pathology**: sequential scans. One `SELECT *`-style pass through a
dataset bigger than the cache evicts *everything* useful. This is why Postgres
ring-buffers large scans and why MySQL's InnoDB inserts new pages at the
*midpoint* of its LRU, not the head.
- **LRU vs LFU**: LRU adapts fast, forgets fast; LFU remembers popularity,
ossifies. TinyLFU (used by Caffeine/Ristretto) staples a frequency sketch
onto an LRU-ish window and beats both.
- **Redis "LRU" is a lie** (an approximation): it samples N random keys and
evicts the stalest. Precise LRU wasn't worth the memory. `allkeys-lru` vs
`volatile-ttl` trade-offs are fair game in SRE interviews.
## Standard follow-up: add TTL
Add `expiresAt time.Time` per entry; on Get, if expired → delete, report miss
(lazy expiry). Optional janitor goroutine with a ticker for proactive sweeps —
now you must also add a `Stop()`/context or you've leaked a goroutine+ticker,
which is a lovely full-circle moment with exercise 03's README.
+58
View File
@@ -0,0 +1,58 @@
// Exercise 05 — LRU Cache
//
// ASSIGNMENT
// ----------
// Implement a thread-safe LRU cache:
//
// 1. NewLRU(capacity): holds at most `capacity` entries.
// 2. Get(key): returns (value, true) and marks the entry most-recently-used;
// (nil, false) on miss.
// 3. Put(key, value): inserts or updates; updating also marks MRU.
// When over capacity, evicts the LEAST recently used entry.
// 4. Both operations O(1). (Hint: map + container/list, or hand-roll a
// doubly linked list.)
// 5. Safe for concurrent use (tests run with -race).
//
// Run the tests: go test -race ./05-lru-cache/
package main
import "fmt"
type LRU struct {
// TODO: fields (mutex, capacity, map[string]*list.Element, *list.List)
// Remember: the list element must store the KEY too, or you can't
// delete the map entry on eviction.
}
// NewLRU creates an LRU cache with the given capacity (capacity >= 1).
// TODO: implement me.
func NewLRU(capacity int) *LRU {
return &LRU{}
}
// Get returns the value and whether it was present. Hit = mark MRU.
// TODO: implement me.
func (c *LRU) Get(key string) (any, bool) {
return nil, false
}
// Put inserts/updates a value, evicting the LRU entry if over capacity.
// TODO: implement me.
func (c *LRU) Put(key string, val any) {
}
// Len returns the current number of entries.
// TODO: implement me.
func (c *LRU) Len() int {
return 0
}
func main() {
c := NewLRU(2)
c.Put("a", 1)
c.Put("b", 2)
c.Get("a") // a is now MRU
c.Put("c", 3) // evicts b
_, ok := c.Get("b")
fmt.Println("b present:", ok) // false
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"fmt"
"sync"
"testing"
)
func TestBasicPutGet(t *testing.T) {
c := NewLRU(2)
c.Put("a", 1)
if v, ok := c.Get("a"); !ok || v.(int) != 1 {
t.Fatalf("want (1,true), got (%v,%v)", v, ok)
}
if _, ok := c.Get("missing"); ok {
t.Fatal("miss reported as hit")
}
}
func TestEvictionOrder(t *testing.T) {
c := NewLRU(2)
c.Put("a", 1)
c.Put("b", 2)
c.Put("c", 3) // evicts a (LRU)
if _, ok := c.Get("a"); ok {
t.Fatal("a should have been evicted")
}
for _, k := range []string{"b", "c"} {
if _, ok := c.Get(k); !ok {
t.Fatalf("%s should still be present", k)
}
}
}
func TestGetRefreshesRecency(t *testing.T) {
c := NewLRU(2)
c.Put("a", 1)
c.Put("b", 2)
c.Get("a") // a becomes MRU
c.Put("c", 3) // must evict b, not a
if _, ok := c.Get("a"); !ok {
t.Fatal("a was evicted despite being MRU — Get must refresh recency")
}
if _, ok := c.Get("b"); ok {
t.Fatal("b should have been evicted")
}
}
func TestUpdateExistingKey(t *testing.T) {
c := NewLRU(2)
c.Put("a", 1)
c.Put("b", 2)
c.Put("a", 99) // update, not insert — must NOT evict
if c.Len() != 2 {
t.Fatalf("update inflated size: len=%d", c.Len())
}
if v, _ := c.Get("a"); v.(int) != 99 {
t.Fatalf("update lost: got %v", v)
}
c.Put("c", 3) // evicts b (a was refreshed by the update)
if _, ok := c.Get("b"); ok {
t.Fatal("b should have been evicted after a's update refreshed it")
}
}
func TestConcurrentAccess(t *testing.T) {
c := NewLRU(64)
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < 500; i++ {
k := fmt.Sprintf("k%d", i%100)
c.Put(k, i)
c.Get(k)
}
}(g)
}
wg.Wait()
if c.Len() > 64 {
t.Fatalf("capacity exceeded under concurrency: %d", c.Len())
}
}