commit 7219eb5a232444cb7bcc9d2fa27b7fee459f5513 Author: Jan Novak Date: Fri Aug 28 22:18:09 2026 +0200 Initial commit: Go SRE interview exercises Includes solved 02-worker-pool and 09-safe-counter (mutex, atomic, sharded). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b5c38d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +.junie/ diff --git a/01-url-health-checker/README.md b/01-url-health-checker/README.md new file mode 100644 index 0000000..cf9f688 --- /dev/null +++ b/01-url-health-checker/README.md @@ -0,0 +1,66 @@ +# 01 — Concurrent URL Health Checker + +The single most frequently asked Go task in SRE interviews. It's a compressed +version of your actual job: blackbox probing with bounded fan-out. + +## The two idioms for bounding concurrency + +**Semaphore** — a buffered channel where the *capacity* is the limit: + +```go +sem := make(chan struct{}, limit) +sem <- struct{}{} // acquire (blocks when full) +<-sem // release +``` + +**Worker pool** — `limit` goroutines ranging over a jobs channel. Semaphore is +less code when each job is independent; worker pool wins when workers carry +state (a connection, a buffer). Be able to write both and *say why you chose one*. + +## Why `results[i]` needs no mutex + +Each goroutine writes to a distinct index. Different elements of a slice are +distinct memory locations, so there's no data race — the race detector agrees. +The `wg.Wait()` creates the happens-before edge that makes all those writes +visible to the reader. This trick ("index-addressed results") deletes an entire +class of locking from your solution. Interviewers notice. + +## The body-drain ritual and why it exists + +```go +io.Copy(io.Discard, resp.Body) +resp.Body.Close() +``` + +`http.Transport` only returns a connection to the keep-alive pool if the body +was fully read. Close-without-drain forces the connection to be torn down → new +TCP+TLS handshake per request → your "health checker" DDoSes your own handshake +path. Real incidents have been caused by exactly this. + +## Things people get wrong here + +- **`http.DefaultClient` has no timeout.** None. A hung server hangs your + checker forever. Always construct your own client, or use + `http.NewRequestWithContext`. +- **A 500 response is not an `error`.** Transport errors (DNS, conn refused, + timeout) populate `err`; HTTP status is data. Conflating them is a classic + monitoring bug (your alerting says "down" when it's actually "up and angry"). +- **Goroutine leak on cancellation**: if you acquire the semaphore with a bare + `sem <- struct{}{}` and ctx dies, goroutines queue up forever. Acquire inside + a `select` with `ctx.Done()`. + +## Level-up flex + +`golang.org/x/sync/errgroup` collapses the whole choreography: + +```go +g, ctx := errgroup.WithContext(ctx) +g.SetLimit(limit) +for i, u := range urls { + g.Go(func() error { results[i] = check(ctx, u); return nil }) +} +g.Wait() +``` + +Mention it exists, then implement the raw version anyway — they want to see +you handle the primitives. diff --git a/01-url-health-checker/main.go b/01-url-health-checker/main.go new file mode 100644 index 0000000..1302ba6 --- /dev/null +++ b/01-url-health-checker/main.go @@ -0,0 +1,53 @@ +// Exercise 01 — Concurrent URL Health Checker +// +// ASSIGNMENT +// ---------- +// Implement CheckAll so that it: +// +// 1. Checks every URL in `urls` with an HTTP GET. +// 2. Runs AT MOST `limit` checks concurrently (bounded concurrency). +// 3. Uses a per-request timeout of 5s AND respects the global `ctx` +// (if ctx is cancelled, pending/unstarted checks must return ctx.Err()). +// 4. Returns one Result per URL, at the SAME index as the input URL. +// 5. Reuses a single http.Client and properly drains+closes response bodies +// so connections are reused. +// +// Run the tests: go test -race ./01-url-health-checker/ +// Run the demo: go run ./01-url-health-checker/ +package main + +import ( + "context" + "fmt" + "time" +) + +type Result struct { + URL string + Status int + Latency time.Duration + Err error +} + +// CheckAll checks all URLs with at most `limit` in flight at once. +// TODO: implement me. +func CheckAll(ctx context.Context, urls []string, limit int) []Result { + results := make([]Result, len(urls)) + // TODO: + // - semaphore (buffered channel) or worker pool for the concurrency limit + // - sync.WaitGroup to wait for completion + // - http.NewRequestWithContext for cancellation + // - write results[i] from goroutine i (why is this safe without a mutex?) + _ = ctx + _ = limit + return results +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + urls := []string{"https://example.com", "https://httpbin.org/status/500", "https://nope.invalid"} + for _, r := range CheckAll(ctx, urls, 2) { + fmt.Printf("%-40s status=%d latency=%v err=%v\n", r.URL, r.Status, r.Latency, r.Err) + } +} diff --git a/01-url-health-checker/main_test.go b/01-url-health-checker/main_test.go new file mode 100644 index 0000000..96735cc --- /dev/null +++ b/01-url-health-checker/main_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestAllURLsChecked(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + urls := []string{srv.URL, srv.URL + "/a", srv.URL + "/b"} + res := CheckAll(context.Background(), urls, 2) + + if len(res) != len(urls) { + t.Fatalf("want %d results, got %d", len(urls), len(res)) + } + for i, r := range res { + if r.URL != urls[i] { + t.Errorf("result %d: URL order not preserved: want %s got %s", i, urls[i], r.URL) + } + if r.Err != nil { + t.Errorf("result %d: unexpected error: %v", i, r.Err) + } + if r.Status != http.StatusOK { + t.Errorf("result %d: want status 200, got %d", i, r.Status) + } + } +} + +func TestErrorsAreReportedNotFatal(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + urls := []string{srv.URL, "http://127.0.0.1:1/unreachable"} + res := CheckAll(context.Background(), urls, 2) + + if res[0].Status != http.StatusServiceUnavailable { + t.Errorf("want 503 for first url, got %d (a 5xx is a RESULT, not an error)", res[0].Status) + } + if res[1].Err == nil { + t.Errorf("want transport error for unreachable url, got nil") + } +} + +func TestConcurrencyLimitRespected(t *testing.T) { + var inFlight, peak atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cur := inFlight.Add(1) + for { + p := peak.Load() + if cur <= p || peak.CompareAndSwap(p, cur) { + break + } + } + time.Sleep(50 * time.Millisecond) + inFlight.Add(-1) + })) + defer srv.Close() + + urls := make([]string, 10) + for i := range urls { + urls[i] = srv.URL + } + const limit = 3 + CheckAll(context.Background(), urls, limit) + + if p := peak.Load(); p > limit { + t.Errorf("concurrency limit violated: peak in-flight = %d, limit = %d", p, limit) + } else if p == 0 { + t.Errorf("no requests observed — is anything running?") + } +} + +func TestContextCancellation(t *testing.T) { + block := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-block + })) + defer srv.Close() + defer close(block) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + urls := []string{srv.URL, srv.URL, srv.URL} + done := make(chan []Result, 1) + go func() { done <- CheckAll(ctx, urls, 1) }() + + select { + case res := <-done: + for i, r := range res { + if r.Err == nil { + t.Errorf("result %d: want error after ctx timeout, got nil", i) + } + } + case <-time.After(2 * time.Second): + t.Fatal("CheckAll did not return after context cancellation — goroutines stuck?") + } +} diff --git a/02-worker-pool/README.md b/02-worker-pool/README.md new file mode 100644 index 0000000..e822093 --- /dev/null +++ b/02-worker-pool/README.md @@ -0,0 +1,54 @@ +# 02 — Worker Pool + +The fundamental fan-out/fan-in shape. Everything else (pipelines, errgroup, +batch processors) is a decoration of this. + +## The channel-closing protocol + +The whole exercise is really about one question: **who closes what, and when?** + +Rules worth engraving: + +1. **Only the sender closes.** A receiver closing a channel = eventual panic on + the sender side (`send on closed channel`). +2. **Close is a broadcast.** Every `range` and every `v, ok := <-ch` across all + receivers unblocks. That's why `close(jobs)` cleanly retires all workers. +3. **With N senders, no single sender may close.** That's the "closer + goroutine" pattern you need here: + +```go +go func() { + wg.Wait() + close(out) +}() +``` + +All N workers send to `out`; a dedicated goroutine waits for all of them and +then closes. This is the only race-free way to close a multi-producer channel. + +4. **You don't have to close channels at all.** Channels are GC'd like anything + else. Closing is *signaling*, not resource cleanup. You close `out` here only + because the consumer ranges over it. + +## The subtle leak in the naive version + +If you forget `close(out)`, the consumer's `range` blocks forever — the test +catches this with a timeout. In prod this presents as goroutine count creeping +up one per request, which you'd see in `go_goroutines` on your dashboard long +before OOM. Well... ideally you would. + +## Why unbuffered `out` is fine (and interesting) + +With `out` unbuffered, workers block until the consumer reads → natural +backpressure, memory bounded by `workers`. Buffering `out` decouples throughput +spikes but the buffer is a queue, and queues hide latency — a very SRE +trade-off to mention out loud. (Little's law applies to your channels too.) + +## Interview follow-ups to prep + +- "Add context cancellation" → every `out <- fn(j)` becomes a `select` with + `ctx.Done()`, and job consumption too. +- "A job can fail" → either a Result{V, Err} envelope type, or switch to + `errgroup` and talk about fail-fast vs collect-all-errors semantics. +- "Jobs arrive faster than they're processed" → bounded jobs channel = + backpressure at the producer; discuss shedding vs queueing. diff --git a/02-worker-pool/main.go b/02-worker-pool/main.go new file mode 100644 index 0000000..cb3b2d3 --- /dev/null +++ b/02-worker-pool/main.go @@ -0,0 +1,61 @@ +// Exercise 02 — Worker Pool +// +// ASSIGNMENT +// ---------- +// Implement Pool so that it: +// +// 1. Spawns exactly `workers` goroutines. +// 2. Each worker consumes jobs from `jobs` until the channel is closed. +// 3. Results of fn(job) are sent to the returned channel. +// 4. The returned channel is CLOSED after ALL workers finish. +// (Whoever ranges over the output must terminate — no deadlocks.) +// 5. No goroutines may leak. +// +// Run the tests: go test -race ./02-worker-pool/ +package main + +import ( + "fmt" + "sync" +) + +// Pool fans `jobs` out to `workers` goroutines running fn, fans results in. +// TODO: implement me. +func Pool[T, R any](jobs <-chan T, workers int, fn func(T) R) <-chan R { + out := make(chan R) + var wg sync.WaitGroup + // start all the workers and increment the waitgroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := range jobs { + out <- fn(j) + } + }() + } + // TODO: + // - start `workers` goroutines, each: for j := range jobs { out <- fn(j) } + // - close(out) exactly once, only after every worker returned + // (hint: sync.WaitGroup + one extra "closer" goroutine) + + // start go routine that closes out channel when all workers are done + go func() { + wg.Wait() + close(out) + }() + return out +} + +func main() { + jobs := make(chan int) + go func() { + for i := 1; i <= 9; i++ { + jobs <- i + } + close(jobs) + }() + for r := range Pool(jobs, 3, func(n int) int { return n * n }) { + fmt.Println(r) + } +} diff --git a/02-worker-pool/main_test.go b/02-worker-pool/main_test.go new file mode 100644 index 0000000..f9e3e1a --- /dev/null +++ b/02-worker-pool/main_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "sort" + "sync/atomic" + "testing" + "time" +) + +func TestAllJobsProcessedAndOutputCloses(t *testing.T) { + jobs := make(chan int) + go func() { + for i := 0; i < 100; i++ { + jobs <- i + } + close(jobs) + }() + + out := Pool(jobs, 5, func(n int) int { return n * 2 }) + + got := []int{} + done := make(chan struct{}) + go func() { + for v := range out { // terminates only if out is closed + got = append(got, v) + } + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("output channel never closed — deadlock or missing close(out)") + } + + if len(got) != 100 { + t.Fatalf("want 100 results, got %d", len(got)) + } + sort.Ints(got) + for i, v := range got { + if v != i*2 { + t.Fatalf("missing/wrong result: index %d = %d", i, v) + } + } +} + +func TestActuallyConcurrent(t *testing.T) { + var inFlight, peak atomic.Int32 + jobs := make(chan int) + go func() { + for i := 0; i < 12; i++ { + jobs <- i + } + close(jobs) + }() + + out := Pool(jobs, 4, func(n int) int { + cur := inFlight.Add(1) + for { + p := peak.Load() + if cur <= p || peak.CompareAndSwap(p, cur) { + break + } + } + time.Sleep(30 * time.Millisecond) + inFlight.Add(-1) + return n + }) + drained := make(chan struct{}) + go func() { + for range out { + } + close(drained) + }() + select { + case <-drained: + case <-time.After(3 * time.Second): + t.Fatal("output never closed") + } + + if p := peak.Load(); p < 2 { + t.Errorf("expected concurrent execution, peak parallelism was %d", p) + } else if p > 4 { + t.Errorf("more than `workers` goroutines running fn: peak %d > 4", p) + } +} + +func TestEmptyJobs(t *testing.T) { + jobs := make(chan int) + close(jobs) + out := Pool(jobs, 3, func(n int) int { return n }) + select { + case _, ok := <-out: + if ok { + t.Fatal("got a value from empty job stream") + } + case <-time.After(time.Second): + t.Fatal("output not closed for empty job stream") + } +} diff --git a/03-rate-limiter/README.md b/03-rate-limiter/README.md new file mode 100644 index 0000000..ae6fe8f --- /dev/null +++ b/03-rate-limiter/README.md @@ -0,0 +1,48 @@ +# 03 — Token Bucket Rate Limiter + +Rate limiting is *the* SRE conversation topic: it appears in load shedding, +API quotas, retry storms, and every postmortem featuring the phrase +"cascading failure." + +## Why lazy refill beats a ticker + +The tempting design — a goroutine adding tokens on a `time.Ticker` — has real +problems: + +- a goroutine + timer **per limiter** (per-user limiters → thousands of tickers), +- refill granularity = tick period (bursty admission at tick boundaries), +- lifecycle: who stops the ticker when the limiter is GC'd? (Nobody. It leaks.) + +Lazy refill computes `tokens += elapsed * rate` on demand: zero background +work, nanosecond-precision, and the limiter is just a struct — GC handles it. +This "compute state from timestamps instead of maintaining it" trick +generalizes (TTL caches, session expiry, leases). + +## Token bucket vs the alternatives (know this table) + +| Algorithm | Bursts? | Memory | Famous gotcha | +|-----------------|------------------|----------|----------------------------------------| +| Token bucket | yes, up to burst | O(1) | float drift over long uptimes | +| Leaky bucket | no (smooths) | O(1) | adds latency by design | +| Fixed window | 2x at boundary | O(1) | 100 req at 0:59 + 100 at 1:01 = "fine" | +| Sliding window | accurate | O(events)| memory scales with rate | + +The fixed-window boundary burst is a favorite interview probe: a limit of +"100/min" admits 200 requests in 2 seconds across a window edge. + +## Monotonic time, or: why your limiter survives NTP + +`time.Now()` in Go carries **both** wall and monotonic clock readings, and +`t2.Sub(t1)` uses the monotonic part. So an NTP step-back won't mint you +negative elapsed time or a token jackpot. This is why you store `last +time.Time` and subtract — never store unix floats and diff them yourself. +(Kernel connection: `CLOCK_MONOTONIC` vs `CLOCK_REALTIME`.) + +## Production answer + +`golang.org/x/time/rate` is the canonical implementation — token bucket with +`Allow`, blocking `Wait(ctx)`, and `Reserve`. Say you'd use it in prod; the +interview wants the from-scratch version to see if you understand what's +inside. Follow-up they love: "make it distributed" → local buckets w/ divided +quota, or centralized counters (Redis + Lua for atomicity), and the CAP-ish +trade-off between accuracy and availability. diff --git a/03-rate-limiter/main.go b/03-rate-limiter/main.go new file mode 100644 index 0000000..e370f2e --- /dev/null +++ b/03-rate-limiter/main.go @@ -0,0 +1,45 @@ +// Exercise 03 — Token Bucket Rate Limiter +// +// ASSIGNMENT +// ---------- +// Implement a token-bucket limiter: +// +// 1. NewLimiter(rate, burst): capacity `burst` tokens, refilled continuously +// at `rate` tokens/second. Bucket starts FULL. +// 2. Allow() consumes one token and returns true, or returns false if no +// token is available. Must be non-blocking and safe for concurrent use. +// 3. Refill must be LAZY — no background goroutine, no ticker. Compute the +// accrued tokens from elapsed time inside Allow(). +// 4. Tokens never exceed `burst` (idle time must not build infinite credit). +// +// Run the tests: go test -race ./03-rate-limiter/ +package main + +import ( + "fmt" + "time" +) + +type Limiter struct { + // TODO: fields (mutex, current tokens, max, rate, last refill time) +} + +// NewLimiter returns a limiter allowing `rate` ops/sec with bursts up to `burst`. +// TODO: implement me. +func NewLimiter(rate float64, burst int) *Limiter { + return &Limiter{} +} + +// Allow reports whether one operation may proceed now. +// TODO: implement me. +func (l *Limiter) Allow() bool { + return false +} + +func main() { + l := NewLimiter(5, 3) // 5/sec, burst 3 + for i := 0; i < 10; i++ { + fmt.Printf("req %d: allowed=%v\n", i, l.Allow()) + time.Sleep(100 * time.Millisecond) + } +} diff --git a/03-rate-limiter/main_test.go b/03-rate-limiter/main_test.go new file mode 100644 index 0000000..a71dbc7 --- /dev/null +++ b/03-rate-limiter/main_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestBurstAllowedThenDenied(t *testing.T) { + l := NewLimiter(1, 3) // slow refill so it doesn't interfere + for i := 0; i < 3; i++ { + if !l.Allow() { + t.Fatalf("request %d within burst should be allowed", i) + } + } + if l.Allow() { + t.Fatal("4th immediate request should be denied (bucket empty)") + } +} + +func TestRefillOverTime(t *testing.T) { + l := NewLimiter(20, 1) // 20 tokens/sec => 1 token per 50ms + if !l.Allow() { + t.Fatal("initial token missing") + } + if l.Allow() { + t.Fatal("bucket should be empty immediately after") + } + time.Sleep(120 * time.Millisecond) // ~2.4 tokens accrued, capped at 1 + if !l.Allow() { + t.Fatal("token should have refilled after wait") + } +} + +func TestBurstIsCapped(t *testing.T) { + l := NewLimiter(1000, 2) + time.Sleep(50 * time.Millisecond) // would accrue ~50 tokens uncapped + allowed := 0 + for i := 0; i < 10; i++ { + if l.Allow() { + allowed++ + } + } + if allowed > 2 { + t.Fatalf("bucket exceeded burst: %d immediate allows (cap 2) — idle time built infinite credit", allowed) + } +} + +func TestConcurrentNoOverAdmission(t *testing.T) { + l := NewLimiter(0.0001, 100) // effectively no refill during the test + var allowed atomic.Int64 + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 10; j++ { + if l.Allow() { + allowed.Add(1) + } + } + }() + } + wg.Wait() + if got := allowed.Load(); got > 100 { + t.Fatalf("over-admission under concurrency: %d > 100 tokens spent", got) + } else if got < 100 { + t.Fatalf("under-admission: only %d of 100 available tokens spent", got) + } +} diff --git a/04-log-topn/README.md b/04-log-topn/README.md new file mode 100644 index 0000000..8c04c59 --- /dev/null +++ b/04-log-topn/README.md @@ -0,0 +1,54 @@ +# 04 — Log Crunching: Top-N + +Looks like a warm-up. It's actually a trap-density championship. + +## Trap 1: `bufio.Scanner`'s 64KB guillotine + +`Scanner` refuses tokens above 64KB by default and stops with +`bufio.ErrTooLong` — and if you forget to check `sc.Err()`, it stops +*silently*, and your report just... misses the tail of the file. Real-world +lines that exceed 64KB: JSON-formatted logs, stack traces, requests with fat +query strings. The fix: + +```go +sc.Buffer(make([]byte, 0, 1024*1024), 1024*1024) +``` + +An enormous number of production log processors have shipped with this bug. +Checking `sc.Err()` after the loop is the difference between a tool and a liar. + +## Trap 2: maps have no order — by design, with a great backstory + +Go **deliberately randomizes** map iteration order (a random seed per map, and +the runtime even randomizes the starting bucket) so nobody can accidentally +depend on it. To rank by count you must extract into a slice and sort: + +```go +sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count }) +``` + +Since 1.24 maps use a Swiss-table implementation (flat, SIMD-probed, cache +friendlier) — fun fact to drop if asked "what's inside a Go map." + +## Trap 3: `strings.Fields` vs `strings.Split` + +`Split(s, " ")` produces empty strings for repeated spaces; `Fields` splits on +any run of whitespace (incl. tabs) and never yields empties. For log parsing, +`Fields` is almost always what you mean. + +## The senior follow-up: "the file is 500 GB" + +Have a ladder ready: + +1. **This solution already streams** — memory is O(distinct endpoints), not + O(file). Say that first; many candidates don't realize their answer scales. +2. **Cardinality explosion** (endpoints contain IDs): normalize paths + (`/users/123` → `/users/:id`) or cap the map with count-min sketch / + space-saving algorithm — approximate top-K in bounded memory. Naming + "count-min sketch" in an SRE interview is a mic-drop; it's what real + metrics pipelines do to survive high-cardinality labels. +3. **Parallelize**: shard by hash(endpoint) across workers, merge partials — + map-reduce in one sentence. +4. **Only need top-N, not full sort?** A min-heap of size N gives + O(distinct · log N) instead of O(distinct · log distinct). `container/heap` + exists; nobody remembers its interface; everyone respects you for trying. diff --git a/04-log-topn/main.go b/04-log-topn/main.go new file mode 100644 index 0000000..03d8dc6 --- /dev/null +++ b/04-log-topn/main.go @@ -0,0 +1,52 @@ +// Exercise 04 — Log Crunching: Top-N Endpoints +// +// ASSIGNMENT +// ---------- +// Log lines look like: +// +// 2026-08-28T10:00:01Z GET /api/users 200 123ms +// +// Implement TopN(r, n) so that it: +// +// 1. Streams the input (must handle logs far larger than memory — no +// io.ReadAll). +// 2. Counts requests per endpoint (3rd whitespace-separated field). +// 3. Skips malformed lines (fewer than 3 fields) without failing. +// 4. Handles lines longer than 64KB (hint: bufio.Scanner's default limit). +// 5. Returns the n most frequent endpoints, most frequent first. +// Ties: order among equal counts is unspecified. +// +// Run the tests: go test -race ./04-log-topn/ +package main + +import ( + "fmt" + "io" + "strings" +) + +type KV struct { + Endpoint string + Count int +} + +// TopN returns the n most frequent endpoints in the log stream. +// TODO: implement me. +func TopN(r io.Reader, n int) ([]KV, error) { + // TODO: + // - bufio.Scanner with an enlarged buffer + // - map[string]int for counting + // - copy map into []KV and sort by count desc (maps can't be sorted) + // - truncate to n + return nil, nil +} + +func main() { + logs := `2026-08-28T10:00:01Z GET /api/users 200 12ms +2026-08-28T10:00:02Z GET /api/orders 200 40ms +2026-08-28T10:00:03Z GET /api/users 500 3ms +garbage line +2026-08-28T10:00:04Z POST /api/users 201 88ms` + top, err := TopN(strings.NewReader(logs), 2) + fmt.Println(top, err) +} diff --git a/04-log-topn/main_test.go b/04-log-topn/main_test.go new file mode 100644 index 0000000..5a3dd46 --- /dev/null +++ b/04-log-topn/main_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTopNBasic(t *testing.T) { + logs := strings.Join([]string{ + "t GET /a 200 1ms", + "t GET /b 200 1ms", + "t GET /a 200 1ms", + "t GET /c 200 1ms", + "t GET /a 200 1ms", + "t GET /b 200 1ms", + }, "\n") + + got, err := TopN(strings.NewReader(logs), 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 results, got %d: %v", len(got), got) + } + if got[0].Endpoint != "/a" || got[0].Count != 3 { + t.Errorf("want /a x3 first, got %+v", got[0]) + } + if got[1].Endpoint != "/b" || got[1].Count != 2 { + t.Errorf("want /b x2 second, got %+v", got[1]) + } +} + +func TestMalformedLinesSkipped(t *testing.T) { + logs := "t GET /a 200 1ms\n\ngarbage\nonly two\nt GET /a 200 1ms\n" + got, err := TopN(strings.NewReader(logs), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].Count != 2 { + t.Fatalf("malformed lines mishandled: %v", got) + } +} + +func TestNLargerThanDistinct(t *testing.T) { + got, err := TopN(strings.NewReader("t GET /a 200 1ms"), 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 { + t.Fatalf("want 1 result, got %d", len(got)) + } +} + +func TestLongLines(t *testing.T) { + // A single line > 64KB (bufio.Scanner default max token size). + long := "t GET /huge?q=" + strings.Repeat("x", 80*1024) + " 200 1ms" + logs := long + "\nt GET /small 200 1ms\nt GET /small 200 1ms\n" + got, err := TopN(strings.NewReader(logs), 1) + if err != nil { + t.Fatalf("long line broke the scanner: %v (raise Scanner.Buffer)", err) + } + if len(got) == 0 || got[0].Endpoint != "/small" { + t.Fatalf("want /small on top, got %v", got) + } +} diff --git a/05-lru-cache/README.md b/05-lru-cache/README.md new file mode 100644 index 0000000..7e65967 --- /dev/null +++ b/05-lru-cache/README.md @@ -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. diff --git a/05-lru-cache/main.go b/05-lru-cache/main.go new file mode 100644 index 0000000..9a0fa06 --- /dev/null +++ b/05-lru-cache/main.go @@ -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 +} diff --git a/05-lru-cache/main_test.go b/05-lru-cache/main_test.go new file mode 100644 index 0000000..fbbe633 --- /dev/null +++ b/05-lru-cache/main_test.go @@ -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()) + } +} diff --git a/06-retry-backoff/README.md b/06-retry-backoff/README.md new file mode 100644 index 0000000..700d5dc --- /dev/null +++ b/06-retry-backoff/README.md @@ -0,0 +1,58 @@ +# 06 — Retry with Backoff + Jitter + +Retries are the most dangerous reliability primitive: applied naively they +convert partial outages into total ones. Interviewers use this task to check +whether you've internalized that or just memorized the loop. + +## Why jitter is non-negotiable (the thundering herd) + +A dependency blips for 2 seconds. 10,000 clients fail *simultaneously*. +Without jitter they all retry at t+1s, t+3s, t+7s — as one synchronized +battering ram. The service recovers, gets hit by a perfectly-phased wave of +10k requests, dies again. Congratulations, your retry policy is now a DDoS +with extra steps — this is a **metastable failure**, and the system can stay +wedged even after the original trigger is gone. + +The AWS Architecture Blog's classic analysis ("Exponential Backoff and +Jitter") compared strategies and **full jitter** — `sleep = rand(0, backoff)` +— came out best: maximal desynchronization, nearly minimal total work. That's +what you implement here. "Equal jitter" (`backoff/2 + rand(0, backoff/2)`) is +the runner-up if you want a floor. + +## `time.Sleep` vs `select` — the test that catches everyone + +```go +select { +case <-time.After(sleep): +case <-ctx.Done(): + return ctx.Err() +} +``` + +`time.Sleep` is uninterruptible. In a shutdown, a worker mid-backoff on a +10-minute sleep holds your rolling deploy hostage until SIGKILL. Selecting on +`ctx.Done()` is what makes retries composable with graceful shutdown +(exercise 08). This single line is the difference between "knows Go" and +"has been paged." + +## Error classification — retrying the unretryable + +Retrying a 400 or a permission error is pure waste; retrying a *non-idempotent* +operation (POST /charge) is a career event. The pattern here uses a sentinel +(`errors.Is(err, ErrRetryable)`); production code often classifies by type +(`errors.As(&netErr)`), by status code, or via an `interface{ Temporary() bool }`. +Bring up **idempotency keys** as the real fix for retrying writes. + +## The complete picture (say this at the whiteboard) + +Retries alone are not a resilience strategy. The full stack: + +1. **Cap the backoff** (`min(backoff, maxBackoff)`) — 2^20 seconds is not a plan. +2. **Retry budgets** (e.g., retries ≤ 10% of requests, à la the SRE book / Finagle) + beat per-request counts — they bound *aggregate* amplification. +3. **Circuit breaker** — stop calling a dead dependency entirely; probe + half-open. Backoff protects *them*, the breaker protects *you*. +4. **Deadline propagation** — retrying past the caller's deadline serves + responses nobody is waiting for. Notice the ctx check does this for free. +5. **Only retry at one layer.** Client retries × mesh retries × app retries = + exponential amplification. Pick an owner. diff --git a/06-retry-backoff/main.go b/06-retry-backoff/main.go new file mode 100644 index 0000000..fd740c4 --- /dev/null +++ b/06-retry-backoff/main.go @@ -0,0 +1,53 @@ +// Exercise 06 — Retry with Exponential Backoff + Jitter +// +// ASSIGNMENT +// ---------- +// Implement Retry(ctx, attempts, base, fn): +// +// 1. Calls fn up to `attempts` times; returns nil on first success. +// 2. Retries ONLY errors matching ErrRetryable (errors.Is — fn may wrap it). +// Non-retryable errors are returned immediately. +// 3. Between attempt i and i+1, sleeps a random duration in +// [0, base * 2^i) — exponential backoff with FULL JITTER. +// 4. The sleep must be interruptible by ctx: if ctx is cancelled mid-sleep, +// return promptly with an error that matches ctx.Err() (errors.Is). +// 5. If all attempts fail, return an error that wraps the last error from fn. +// 6. Never sleep after the final attempt. +// +// Run the tests: go test -race ./06-retry-backoff/ +package main + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ErrRetryable marks transient failures. fn returns errors wrapping this +// to request a retry. +var ErrRetryable = errors.New("retryable") + +// Retry runs fn with exponential backoff + full jitter. +// TODO: implement me. +func Retry(ctx context.Context, attempts int, base time.Duration, fn func(context.Context) error) error { + // TODO: + // - loop attempts times, call fn + // - errors.Is(err, ErrRetryable) to classify + // - backoff := base << i ; sleep := rand in [0, backoff) + // - select { time.After(sleep) / ctx.Done() } + // - wrap the final error with fmt.Errorf("... %w", err) + return nil +} + +func main() { + i := 0 + err := Retry(context.Background(), 5, 50*time.Millisecond, func(ctx context.Context) error { + i++ + if i < 3 { + return fmt.Errorf("attempt %d flaked: %w", i, ErrRetryable) + } + return nil + }) + fmt.Println("attempts:", i, "err:", err) +} diff --git a/06-retry-backoff/main_test.go b/06-retry-backoff/main_test.go new file mode 100644 index 0000000..a9983cc --- /dev/null +++ b/06-retry-backoff/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "errors" + "fmt" + "testing" + "time" +) + +func TestSucceedsAfterTransientFailures(t *testing.T) { + calls := 0 + err := Retry(context.Background(), 5, time.Millisecond, func(ctx context.Context) error { + calls++ + if calls < 3 { + return fmt.Errorf("flaky: %w", ErrRetryable) + } + return nil + }) + if err != nil { + t.Fatalf("want success, got %v", err) + } + if calls != 3 { + t.Fatalf("want 3 calls, got %d", calls) + } +} + +func TestNonRetryableStopsImmediately(t *testing.T) { + fatal := errors.New("permission denied") + calls := 0 + err := Retry(context.Background(), 5, time.Millisecond, func(ctx context.Context) error { + calls++ + return fatal + }) + if calls != 1 { + t.Fatalf("non-retryable error must stop retries: %d calls", calls) + } + if !errors.Is(err, fatal) { + t.Fatalf("returned error should match/wrap the fatal error, got %v", err) + } +} + +func TestExhaustionWrapsLastError(t *testing.T) { + calls := 0 + err := Retry(context.Background(), 3, time.Millisecond, func(ctx context.Context) error { + calls++ + return fmt.Errorf("boom %d: %w", calls, ErrRetryable) + }) + if calls != 3 { + t.Fatalf("want 3 attempts, got %d", calls) + } + if err == nil || !errors.Is(err, ErrRetryable) { + t.Fatalf("exhaustion error must wrap the last attempt error, got %v", err) + } +} + +func TestContextCancelsSleep(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := Retry(ctx, 10, 10*time.Second, func(ctx context.Context) error { + return fmt.Errorf("always: %w", ErrRetryable) + }) + elapsed := time.Since(start) + + if elapsed > time.Second { + t.Fatalf("Retry ignored ctx during backoff sleep: took %v (time.Sleep instead of select?)", elapsed) + } + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("want error matching context.Canceled, got %v", err) + } +} + +func TestBackoffActuallyWaits(t *testing.T) { + start := time.Now() + _ = Retry(context.Background(), 3, 40*time.Millisecond, func(ctx context.Context) error { + return fmt.Errorf("nope: %w", ErrRetryable) + }) + // Full jitter lower bound is 0, so we can't assert a minimum reliably — + // but the loop must finish in bounded time: < base*(2^0+2^1+2^2) + slack. + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("backoff far too long (sleeping after last attempt?): %v", elapsed) + } +} diff --git a/07-fan-in/README.md b/07-fan-in/README.md new file mode 100644 index 0000000..820aa03 --- /dev/null +++ b/07-fan-in/README.md @@ -0,0 +1,65 @@ +# 07 — Fan-in (Merge) + +The mirror image of the worker pool: N producers, one stream. Every metrics +pipeline, event aggregator, and multi-shard reader is this shape. + +## The double-select most solutions miss + +A forwarder that's correct under cancellation needs `ctx.Done()` in **two** +places: + +```go +for { + v, ok := <-ch // (1) can block forever if input never closes + if !ok { return } + out <- v // (2) can block forever if consumer walked away +} +``` + +Both blocking points must become selects. Miss (2) and you have the sneakier +leak: the consumer stops reading (their own ctx died), your forwarders sit +parked on `out <- v` until process exit. The test cancels *while* you're +blocked on send, specifically to catch this. In pprof this leak has a +signature: dozens of goroutines parked in `chan send` inside your merge +function, ages in the minutes column. + +## `runtime.NumGoroutine()` as a test assertion + +Counting goroutines before/after is a legit leak-detection technique — crude +but effective. The polished version is `goleak` (from Uber), worth namedropping: +it snapshots goroutines at test end and fails on strays. In production the +equivalent is watching `go_goroutines` from the Prometheus client — a sawtooth +that only goes up is a leak with a deadline. + +## Nil-channel select: the elegant alternative + +For a *small, fixed* number of inputs there's a beautiful trick — select over +all inputs directly and set a channel variable to `nil` when it closes: + +```go +for a != nil || b != nil { + select { + case v, ok := <-a: + if !ok { a = nil; continue } // case permanently disabled + out <- v + case v, ok := <-b: + if !ok { b = nil; continue } + out <- v + } +} +``` + +Ops on nil channels block forever, so a nil case simply never fires — it's the +idiomatic way to *disable* a select arm at runtime. This version needs no +WaitGroup and no forwarder goroutines. Know both: goroutine-per-input scales +to dynamic N; nil-channel select wins for 2–3 known inputs. Explaining *why* +the nil trick works (nil channel semantics) is exactly the kind of +language-lawyer flex these interviews reward. + +## Bonus rabbit hole: what select actually does + +With multiple ready cases, `select` picks **uniformly at random** — the runtime +shuffles case order (`selectgo`) to prevent starvation. So merge output +ordering isn't just "unspecified," it's actively randomized under contention. +Determinism-dependent tests on select ordering are therefore always flaky — +a code-review comment you'll get to make at least once a year for free. diff --git a/07-fan-in/main.go b/07-fan-in/main.go new file mode 100644 index 0000000..bdb1b05 --- /dev/null +++ b/07-fan-in/main.go @@ -0,0 +1,49 @@ +// Exercise 07 — Fan-in / Merge Channels +// +// ASSIGNMENT +// ---------- +// Implement Merge(ctx, chs...): +// +// 1. Returns a single channel carrying every value from every input channel. +// 2. The output channel is CLOSED once all input channels are closed and +// drained. +// 3. If ctx is cancelled, Merge stops forwarding, and all its internal +// goroutines exit even if inputs never close and even if nobody reads +// the output anymore. (No goroutine leaks — the tests count goroutines.) +// 4. Order across inputs is unspecified. +// +// Run the tests: go test -race ./07-fan-in/ +package main + +import ( + "context" + "fmt" +) + +// Merge fans in all input channels into one output channel. +// TODO: implement me. +func Merge[T any](ctx context.Context, chs ...<-chan T) <-chan T { + out := make(chan T) + // TODO: + // - one forwarder goroutine per input channel + // - each forward is a select: {out <- v} vs {<-ctx.Done()} + // - reading the input must ALSO select on ctx.Done() + // - closer goroutine: wg.Wait(); close(out) + return out +} + +func main() { + gen := func(vals ...int) <-chan int { + ch := make(chan int) + go func() { + defer close(ch) + for _, v := range vals { + ch <- v + } + }() + return ch + } + for v := range Merge(context.Background(), gen(1, 2, 3), gen(10, 20), gen(100)) { + fmt.Println(v) + } +} diff --git a/07-fan-in/main_test.go b/07-fan-in/main_test.go new file mode 100644 index 0000000..5050642 --- /dev/null +++ b/07-fan-in/main_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "runtime" + "sort" + "testing" + "time" +) + +func gen(vals ...int) <-chan int { + ch := make(chan int) + go func() { + defer close(ch) + for _, v := range vals { + ch <- v + } + }() + return ch +} + +func TestMergeAllValues(t *testing.T) { + out := Merge(context.Background(), gen(1, 2, 3), gen(4, 5), gen(6)) + got := []int{} + done := make(chan struct{}) + go func() { + for v := range out { + got = append(got, v) + } + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("output never closed after all inputs closed") + } + sort.Ints(got) + want := []int{1, 2, 3, 4, 5, 6} + if len(got) != len(want) { + t.Fatalf("want %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("want %v, got %v", want, got) + } + } +} + +func TestNoInputs(t *testing.T) { + out := Merge[int](context.Background()) + select { + case _, ok := <-out: + if ok { + t.Fatal("value from zero inputs") + } + case <-time.After(time.Second): + t.Fatal("output not closed with zero inputs") + } +} + +func TestCancellationReleasesGoroutines(t *testing.T) { + before := runtime.NumGoroutine() + + ctx, cancel := context.WithCancel(context.Background()) + // Inputs that never close and keep producing. + mk := func() <-chan int { + ch := make(chan int) + go func() { + for { + select { + case ch <- 1: + case <-ctx.Done(): + return + } + } + }() + return ch + } + out := Merge(ctx, mk(), mk(), mk()) + + // Read a few values, then walk away AND cancel — forwarders must not + // stay blocked on `out <- v`. + for i := 0; i < 5; i++ { + select { + case <-out: + case <-time.After(2 * time.Second): + cancel() + t.Fatal("no values arriving on merged channel") + } + } + cancel() + + deadline := time.After(2 * time.Second) + for { + runtime.GC() + if runtime.NumGoroutine() <= before+2 { // small slack + return + } + select { + case <-deadline: + t.Fatalf("goroutine leak after cancel: before=%d now=%d", before, runtime.NumGoroutine()) + case <-time.After(20 * time.Millisecond): + } + } +} diff --git a/08-graceful-shutdown/README.md b/08-graceful-shutdown/README.md new file mode 100644 index 0000000..355bc98 --- /dev/null +++ b/08-graceful-shutdown/README.md @@ -0,0 +1,66 @@ +# 08 — Graceful Shutdown + +Pure SRE bread and butter: every rolling deploy, node drain, and spot +eviction ends with this code path. Interviewers love it because it chains +signals, contexts, and HTTP internals into one small program. + +## The fresh-context trick (the whole exercise in one line) + +```go +<-ctx.Done() // SIGTERM arrived; ctx is DEAD +shutCtx, cancel := context.WithTimeout(context.Background(), drain) +srv.Shutdown(shutCtx) +``` + +`Shutdown`'s context bounds *how long draining may take*. Pass the +already-cancelled signal ctx and Shutdown gives up instantly — you built +"graceful" shutdown that guillotines every in-flight request. It has to derive +from `Background()`. This inverted-context moment is the most commonly botched +line in the whole genre; the `TestDrainDeadlineExceeded`/`TestInFlight` pair +exists to catch both failure modes. + +## `http.ErrServerClosed` is a success + +`ListenAndServe` *always* returns non-nil. After `Shutdown` it returns +`http.ErrServerClosed`, which means "you asked me to stop and I did." Filter +it with `errors.Is`. Logging it as fatal is the "error: success" log line +every fleet has somewhere. + +Also: send it through a **buffered** channel (`make(chan error, 1)`) — if you +exit `Run` via the ctx path first, an unbuffered send would strand the +ListenAndServe goroutine forever. Tiny buffer, whole leak class gone. + +## What Shutdown actually does + +1. Closes listeners → new connects get refused (RST). +2. Closes *idle* keep-alive connections. +3. Waits for active requests to finish, polling, until its ctx expires. +4. Returns `ctx.Err()` if the drain deadline passed with requests still going. + +Note what it does **not** do: notify your load balancer. Which brings us to… + +## The Kubernetes choreography (senior talking points) + +SIGTERM and "stop receiving traffic" are **not ordered** the way you'd hope. +Kubelet sends SIGTERM *and* starts removing the pod from Endpoints +concurrently — traffic can arrive *after* SIGTERM for however long it takes +every kube-proxy and LB in the fleet to converge. Hence the cargo-cult that +is actually correct: + +- **`preStop: sleep 5-15s`** — not superstition; it's buying time for + endpoint propagation *before* the process even sees SIGTERM. +- Fail readiness on SIGTERM, keep serving while draining. +- `terminationGracePeriodSeconds` (default 30s) must exceed + preStop + drainTimeout, or SIGKILL wins and no code of yours runs at all. +- If you're PID 1 in a container without an init, you get no default signal + handlers — `signal.NotifyContext` is doing real work there. (Related deep + cut: PID 1 doesn't reap zombies unless you make it — `tini` exists for a + reason.) + +## `signal.NotifyContext` fine print + +Second signal = default action (instant death). That's a *feature*: hung +drain + impatient operator's second Ctrl+C = immediate exit. Calling the +returned `stop()` after `ctx.Done()` restores default handling explicitly for +the same effect. Mentioning "first SIGTERM drains, second kills" is a nice +operational garnish. diff --git a/08-graceful-shutdown/main.go b/08-graceful-shutdown/main.go new file mode 100644 index 0000000..525f695 --- /dev/null +++ b/08-graceful-shutdown/main.go @@ -0,0 +1,69 @@ +// Exercise 08 — Graceful HTTP Server Shutdown +// +// ASSIGNMENT +// ---------- +// Implement NewServer and Run: +// +// 1. NewServer(addr, handler) returns an *http.Server with EXPLICIT +// ReadTimeout, WriteTimeout and IdleTimeout (pick sane values; the +// zero-value server has none, which is the bug). +// 2. Run(ctx, srv, drainTimeout): +// a. Starts the server (ListenAndServe) in a goroutine. +// b. Blocks until ctx is cancelled (that's your SIGTERM signal). +// c. Then calls srv.Shutdown with a FRESH context carrying +// drainTimeout — NOT the already-cancelled ctx (why? see README). +// d. In-flight requests must be allowed to complete (up to drainTimeout); +// new connections are refused during drain. +// e. Returns nil on clean drain; returns an error if the drain deadline +// was exceeded or ListenAndServe failed for a real reason. +// (http.ErrServerClosed is NOT a real reason.) +// +// Run the tests: go test -race ./08-graceful-shutdown/ +// Run the demo: go run ./08-graceful-shutdown/ (then Ctrl+C) +package main + +import ( + "context" + "fmt" + "net/http" + "os/signal" + "syscall" + "time" +) + +// NewServer builds an http.Server with production timeouts. +// TODO: implement me. +func NewServer(addr string, h http.Handler) *http.Server { + return &http.Server{} +} + +// Run serves until ctx is cancelled, then drains gracefully. +// TODO: implement me. +func Run(ctx context.Context, srv *http.Server, drainTimeout time.Duration) error { + // TODO: + // - errCh := make(chan error, 1); go srv.ListenAndServe() into it + // (why buffered? so the goroutine never leaks if we exit via ctx) + // - select on ctx.Done() vs errCh + // - on ctx.Done(): context.WithTimeout(context.Background(), drainTimeout) + // and srv.Shutdown(shutCtx) + // - filter http.ErrServerClosed with errors.Is + return nil +} + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + + mux := http.NewServeMux() + mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(3 * time.Second) + fmt.Fprintln(w, "done") + }) + + srv := NewServer(":8080", mux) + fmt.Println("serving on :8080 — curl localhost:8080/slow, then Ctrl+C mid-request") + if err := Run(ctx, srv, 10*time.Second); err != nil { + fmt.Println("shutdown error:", err) + } + fmt.Println("bye") +} diff --git a/08-graceful-shutdown/main_test.go b/08-graceful-shutdown/main_test.go new file mode 100644 index 0000000..4370e48 --- /dev/null +++ b/08-graceful-shutdown/main_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "io" + "net" + "net/http" + "testing" + "time" +) + +// testClient disables keep-alives: the default transport may speculatively +// dial a second connection and park it in StateNew without ever sending a +// request — and Server.Shutdown waits for StateNew conns, deadlocking the +// drain. A fun wart, but not the one this exercise is about. +var testClient = &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} + +func freeAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().String() + l.Close() + return addr +} + +func waitReady(t *testing.T, url string) { + t.Helper() + for i := 0; i < 100; i++ { + if resp, err := testClient.Get(url); err == nil { + resp.Body.Close() + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("server never became ready") +} + +func TestServerHasTimeouts(t *testing.T) { + srv := NewServer(":0", http.NewServeMux()) + if srv.ReadTimeout == 0 || srv.WriteTimeout == 0 || srv.IdleTimeout == 0 { + t.Fatalf("server must set Read/Write/Idle timeouts explicitly (got %v/%v/%v) — zero means unlimited", + srv.ReadTimeout, srv.WriteTimeout, srv.IdleTimeout) + } + if srv.Addr != ":0" || srv.Handler == nil { + t.Fatal("Addr/Handler not wired through") + } +} + +func TestServesAndStopsOnCancel(t *testing.T) { + addr := freeAddr(t) + mux := http.NewServeMux() + mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- Run(ctx, NewServer(addr, mux), 5*time.Second) }() + + waitReady(t, "http://"+addr+"/ok") + cancel() + + select { + case err := <-done: + if err != nil { + t.Fatalf("clean shutdown should return nil, got %v (leaking http.ErrServerClosed?)", err) + } + case <-time.After(3 * time.Second): + t.Fatal("Run did not return after ctx cancel") + } +} + +func TestInFlightRequestCompletes(t *testing.T) { + addr := freeAddr(t) + started := make(chan struct{}) + mux := http.NewServeMux() + mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }) + mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { + close(started) + time.Sleep(500 * time.Millisecond) + io.WriteString(w, "slow-done") + }) + + ctx, cancel := context.WithCancel(context.Background()) + runDone := make(chan error, 1) + go func() { runDone <- Run(ctx, NewServer(addr, mux), 5*time.Second) }() + waitReady(t, "http://"+addr+"/ok") + + reqDone := make(chan string, 1) + go func() { + resp, err := testClient.Get("http://" + addr + "/slow") + if err != nil { + reqDone <- "error: " + err.Error() + return + } + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + reqDone <- string(b) + }() + + <-started // request is in flight + cancel() // SIGTERM arrives mid-request + + if body := <-reqDone; body != "slow-done" { + t.Fatalf("in-flight request was killed instead of drained: got %q", body) + } + if err := <-runDone; err != nil { + t.Fatalf("drain within deadline should return nil, got %v", err) + } +} + +func TestDrainDeadlineExceeded(t *testing.T) { + addr := freeAddr(t) + started := make(chan struct{}) + mux := http.NewServeMux() + mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }) + mux.HandleFunc("/hang", func(w http.ResponseWriter, r *http.Request) { + close(started) + time.Sleep(5 * time.Second) + }) + + ctx, cancel := context.WithCancel(context.Background()) + runDone := make(chan error, 1) + go func() { runDone <- Run(ctx, NewServer(addr, mux), 200*time.Millisecond) }() + waitReady(t, "http://"+addr+"/ok") + + go func() { testClient.Get("http://" + addr + "/hang") }() + <-started + cancel() + + select { + case err := <-runDone: + if err == nil { + t.Fatal("want error when drain deadline exceeded, got nil") + } + case <-time.After(3 * time.Second): + t.Fatal("Run hung past the drain deadline — Shutdown ctx wired up wrong?") + } +} diff --git a/09-safe-counter/README.md b/09-safe-counter/README.md new file mode 100644 index 0000000..4ac236d --- /dev/null +++ b/09-safe-counter/README.md @@ -0,0 +1,54 @@ +# 09 — Safe Counters, Three Ways + +Trivial-looking, but it's the interviewer's synchronization litmus test — and +the benchmark at the end teaches something real about multicore hardware. + +## What a data race actually is (get the definition razor-sharp) + +Two accesses to the same memory location, at least one a write, not ordered by +a happens-before edge. Not "they happen at the same time" — *unordered*. The +Go memory model promises basically nothing for racy programs (a racy read can +observe a torn/stale/impossible value), so "it works on my machine" is not +evidence of anything. `-race` instruments happens-before via vector clocks +(ThreadSanitizer); it has ~5-10x slowdown, only catches races that *occur* +during the run, and is still the best deal in software testing. Run +integration environments with it if you can afford to. + +Bonus distinction interviewers fish for: **data race** (memory-level, defined +above) vs **race condition** (logic-level, e.g. check-then-act on a file that +another process swaps). You can have either without the other. + +## Mutex vs atomic — when each + +- `atomic.Int64` for independent counters/flags/gauges: lock-free, a single + LOCK-prefixed instruction on amd64. Use the typed API (`atomic.Int64`), not + the legacy `atomic.AddInt64(&x, 1)` — the typed one can't be misused with + an unaligned or copied address. +- The moment an operation touches **two things that must stay consistent** + (map + list in the LRU, tokens + timestamp in the limiter), atomics stop + composing and you want a mutex. "Atomics don't compose" is the sentence. +- `sync.Map`: only for append-mostly maps or disjoint per-goroutine keysets + (its docstring says so). A mutexed map beats it for general workloads. + +## Why sharding works: false sharing and cache lines + +Run the benchmarks with `-cpu=1,4,8`. The single-mutex counter gets *slower* +with more cores: every Inc bounces the mutex's cache line between cores +(cache-coherence traffic, MESI protocol), and cores stall waiting for +exclusive ownership. Sharding gives each hot key set its own mutex on its own +cache line → contention divides by shard count. + +The deep cut: even *separate* atomics hurt if they share a 64-byte cache +line — **false sharing**. High-performance Go code pads hot fields +(`_ [64]byte`) or uses `runtime/internal`-style per-P structures. This is +exactly why Prometheus client_golang counters are fast: per-goroutine-ish +sharded values summed on scrape. + +## Snapshot-as-copy: the API design point + +Returning the internal map hands the caller a live reference into your +critical section — they'll range over it while you write, and the runtime +throws `concurrent map iteration and map write` (fatal, unrecoverable, not a +panic you can catch). Copy under the lock, return the copy. General principle: +**never let references escape the mutex**. Same reason you don't return +internal slices from locked structs without copying. diff --git a/09-safe-counter/main.go b/09-safe-counter/main.go new file mode 100644 index 0000000..7e63e2d --- /dev/null +++ b/09-safe-counter/main.go @@ -0,0 +1,131 @@ +// Exercise 09 — Concurrency-Safe Counters (three ways) +// +// ASSIGNMENT +// ---------- +// A warm-up screener that interviewers run with -race. Implement THREE +// versions of a keyed counter and one plain counter: +// +// 1. MutexCounter: map[string]int64 guarded by a sync.Mutex. +// Inc(key), Get(key), Snapshot() (a COPY of the map — why a copy? if you +// return the internal map, the caller can race on it). +// 2. AtomicCounter: a single int64 total using sync/atomic only. +// Inc(), Get(). No mutex allowed. +// 3. ShardedCounter: N shards, each its own mutex+map, key routed by hash. +// Same interface as MutexCounter. This is how you fix mutex contention +// on hot maps. +// +// Run the tests: go test -race ./09-safe-counter/ +// Then benchmark: go test -bench=. -cpu=1,4,8 ./09-safe-counter/ +package main + +import ( + "fmt" + "hash/fnv" + "sync" + "sync/atomic" +) + +// ---- 1. Mutex version ---- + +type MutexCounter struct { + mu sync.Mutex + m map[string]int64 +} + +func NewMutexCounter() *MutexCounter { + return &MutexCounter{ + m: make(map[string]int64), + } +} +func (c *MutexCounter) Inc(key string) { + c.mu.Lock() + defer c.mu.Unlock() + c.m[key]++ + +} +func (c *MutexCounter) Get(key string) int64 { + c.mu.Lock() + defer c.mu.Unlock() + return (c.m[key]) +} +func (c *MutexCounter) Snapshot() map[string]int64 { + c.mu.Lock() + defer c.mu.Unlock() + // possibly could use maps.clone + outmap := make(map[string]int64, len(c.m)) + for k, v := range c.m { + outmap[k] = v + } + return outmap +} + +// ---- 2. Atomic version ---- + +type AtomicCounter struct { + n atomic.Int64 +} + +func NewAtomicCounter() *AtomicCounter { return &AtomicCounter{} } +func (c *AtomicCounter) Inc() { c.n.Add(1) } +func (c *AtomicCounter) Get() int64 { return c.n.Load() } + +// ---- 3. Sharded version ---- + +const shards = 16 + +type shard struct { + mu sync.Mutex + m map[string]int64 +} +type ShardedCounter struct { + s [shards]shard + // route with fnv-1a: hash(key) % shards (hash/fnv or hand-rolled) +} + +func NewShardedCounter() *ShardedCounter { + c := &ShardedCounter{} + // loop over shards and initialize each shard's map + for i := 0; i < shards; i++ { + c.s[i].m = make(map[string]int64) + } + return c +} +func (c *ShardedCounter) Inc(key string) { + // find shard for the key + hashF := fnv.New32a() + hashF.Write([]byte(key)) + shard := hashF.Sum32() % shards + + c.s[shard].mu.Lock() + defer c.s[shard].mu.Unlock() + // fmt.Println("shard", shard, "key", key, "value", c.s[shard].m[key]) + c.s[shard].m[key]++ +} +func (c *ShardedCounter) Get(key string) int64 { + // find shard for the key + hashF := fnv.New32a() + hashF.Write([]byte(key)) + shard := hashF.Sum32() % shards + + c.s[shard].mu.Lock() + defer c.s[shard].mu.Unlock() + return c.s[shard].m[key] +} +func (c *ShardedCounter) Snapshot() map[string]int64 { + out := make(map[string]int64) + for i := 0; i < shards; i++ { + c.s[i].mu.Lock() + for k, v := range c.s[i].m { + out[k] = v + } + c.s[i].mu.Unlock() + } + return out +} + +func main() { + c := NewMutexCounter() + c.Inc("requests") + c.Inc("requests") + fmt.Println(c.Get("requests"), c.Snapshot()) +} diff --git a/09-safe-counter/main_test.go b/09-safe-counter/main_test.go new file mode 100644 index 0000000..a336163 --- /dev/null +++ b/09-safe-counter/main_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "sync" + "testing" +) + +const ( + goroutines = 16 + perG = 1000 +) + +func TestMutexCounter(t *testing.T) { + c := NewMutexCounter() + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < perG; i++ { + c.Inc(fmt.Sprintf("k%d", i%4)) + } + }(g) + } + wg.Wait() + var total int64 + for i := 0; i < 4; i++ { + total += c.Get(fmt.Sprintf("k%d", i)) + } + if want := int64(goroutines * perG); total != want { + t.Fatalf("lost updates: want %d got %d", want, total) + } +} + +func TestSnapshotIsACopy(t *testing.T) { + c := NewMutexCounter() + c.Inc("a") + snap := c.Snapshot() + if snap == nil || snap["a"] != 1 { + t.Fatalf("bad snapshot: %v", snap) + } + snap["a"] = 999 // mutating the snapshot must not affect the counter + if c.Get("a") != 1 { + t.Fatal("Snapshot returned the internal map, not a copy") + } + // And writing to the snapshot while counting must not race (-race checks). + var wg sync.WaitGroup + wg.Add(1) + go func() { defer wg.Done(); for i := 0; i < 100; i++ { c.Inc("a") } }() + for i := 0; i < 100; i++ { + snap["b"] = int64(i) + } + wg.Wait() +} + +func TestAtomicCounter(t *testing.T) { + c := NewAtomicCounter() + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < perG; i++ { + c.Inc() + } + }() + } + wg.Wait() + if want := int64(goroutines * perG); c.Get() != want { + t.Fatalf("lost updates: want %d got %d", want, c.Get()) + } +} + +func TestShardedCounter(t *testing.T) { + c := NewShardedCounter() + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < perG; i++ { + c.Inc(fmt.Sprintf("key-%d", i%50)) + } + }(g) + } + wg.Wait() + var total int64 + for k, v := range c.Snapshot() { + _ = k + total += v + } + if want := int64(goroutines * perG); total != want { + t.Fatalf("lost updates across shards: want %d got %d", want, total) + } + if c.Get("key-0") == 0 { + t.Fatal("Get by key broken") + } +} + +func BenchmarkMutexCounter(b *testing.B) { + c := NewMutexCounter() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Inc("hot") + } + }) +} + +func BenchmarkShardedCounter(b *testing.B) { + c := NewShardedCounter() + keys := [8]string{"a", "b", "c", "d", "e", "f", "g", "h"} + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + c.Inc(keys[i&7]) + i++ + } + }) +} + +func BenchmarkAtomicCounter(b *testing.B) { + c := NewAtomicCounter() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Inc() + } + }) +} diff --git a/10-gotchas/README.md b/10-gotchas/README.md new file mode 100644 index 0000000..087dd58 --- /dev/null +++ b/10-gotchas/README.md @@ -0,0 +1,129 @@ +# 10 — Gotchas: "What Does This Print?" + +The trivia round. Each subfolder is a tiny program. The drill: + +1. Open `main.go`, **predict the exact output** (or panic/deadlock/compile error). +2. `go run ./10-gotchas/exNN-.../` and check yourself. +3. Read the answer below — then explain it OUT LOUD in one breath, because + that's the actual interview skill. + +Note: ex11 intentionally does not compile, and ex02 intentionally deadlocks — +that IS the exercise. (This folder is excluded from `go build ./...` sanity by +being all `main` packages; run them individually.) + +--- + +## Answers (no peeking before predicting) + +
ex01 — loop variable capture + +Go ≥1.22: `0 1 2` in some order — the `for` statement gives each iteration a +fresh `i` (spec change in 1.22, one of the very few semantic changes ever made +to Go). Pre-1.22: typically `3 3 3` — all closures captured the *same* +variable, which was 3 by the time they ran. Old fixes: `go func(i int){...}(i)` +or shadowing `i := i`. Always mention the version boundary; it proves your +knowledge has a timestamp. +
+ +
ex02 — unbuffered send deadlock + +`fatal error: all goroutines are asleep - deadlock!` An unbuffered send blocks +until a receiver is ready; there is no other goroutine. Fix A: +`make(chan int, 1)`. Fix B: do the send or receive in another goroutine. +Key nuance: the runtime detects only *global* deadlock (every goroutine +blocked). One stuck goroutine among many = silent leak, no error, ever. +
+ +
ex03 — closed channel semantics + +Prints `1`, then `0 false`, then **panics**: `close of closed channel`. +Buffered values survive close and drain normally; after drain, receive returns +zero value + `ok=false` *immediately* (never blocks). Send on closed also +panics. Recite the rules: only the sender closes; with multiple senders, +nobody closes directly (coordinate via WaitGroup); closing is signaling, not +cleanup — unclosed channels are GC'd fine. +
+ +
ex04 — nil channel in select + +`b 42`. Operations on nil channels block forever, so a nil case is simply +never ready — select ignores it. If *all* cases were nil (and no `default`): +deadlock. This is a load-bearing idiom: set a channel var to nil to disable +its case dynamically (see exercise 07's README for it powering a fan-in). +
+ +
ex05 — slice aliasing via append + +`[1 2 99 4]` and `[1 2 3 4]`. First: `a[:2]` has len 2, **cap 4** — append +fits within cap, writes into the shared backing array, clobbers `a[2]`. +Second: full slice expression `a[:2:2]` caps the cap at 2 — append must +reallocate, `a` untouched. Corollaries: this is why `append` returns a value; +why functions receiving slices can visibly mutate the caller's data *sometimes* +(only when no realloc happened — the worst kind of bug); and what +`s[low:high:max]` is actually for. +
+ +
ex06 — defer + named returns + +`6` then `5`; then `deferred: 2, 1, 0` at main's exit, LIFO. In `f`, +`return 5` assigns to the *named* return `x`, then defers run and mutate it → +6. In `g`, `return x` copies into an anonymous slot first → the defer's +increment is invisible. Also hiding here: `defer fmt.Println("deferred:", i)` +evaluates the *argument* `i` at defer time — so it's the deferred *closures* +that capture, plain calls snapshot. Practical use of the f-pattern: wrapping +errors on the way out via `defer func() { err = ... }()` with a named `err`. +
+ +
ex07 — typed nil interface + +Prints `got error: boom` — the `err != nil` branch runs. An interface value is +a (type, value) pair; here it's (`*MyErr`, nil) — a non-nil interface wrapping +a nil pointer. Interface == nil only when BOTH halves are nil. (The method +call then works because `Error()` never dereferences its nil receiver — nil +receivers are legal in Go.) Fix: return literal `nil`, or declare +`var err error` and assign. If you can whiteboard the two-word pair, you own +this question. +
+ +
ex08 — concurrent map writes + +Very likely dies with `fatal error: concurrent map writes` — and `recovered:` +never prints, because it's a **runtime fatal error (throw), not a panic**: +`recover()` cannot catch it, the process is gone. (Deliberate design: a +corrupted map is unrecoverable state.) Fixes: mutex, `sync.Map` (append-mostly +workloads only), or sharding. `-race` flags it deterministically even on runs +that happen not to crash. +
+ +
ex09 — WaitGroup Add placement + +Race: main can reach `wg.Wait()` while the counter is still 0 (no goroutine +has run `Add` yet) → Wait returns instantly, prints `count: 0..3` — any value. +Worse interleavings hit "WaitGroup misuse: Add called concurrently with Wait". +Rule: `Add` in the *spawning* goroutine, before `go`. Plus `count++` is itself +a data race (`-race` flags both). And always pass `*sync.WaitGroup` — copying +one with nonzero state breaks the count (`go vet` catches the copy). +
+ +
ex10 — goroutine leak on timeout + +Prints ~101 goroutines instead of 1. On timeout, nobody ever receives from the +unbuffered `ch`; the worker goroutine blocks on `ch <- "body"` forever. At +100 rps against a slow upstream that's OOM with a countdown. The +one-character-ish fix: `make(chan string, 1)` — the send always succeeds, the +orphaned result gets GC'd with the channel. The structural fix: pass ctx into +the work and select the send against `ctx.Done()`. Detection in prod: +`go_goroutines` metric trending up + pprof goroutine profile showing an army +parked in `chan send` at one line. +
+ +
ex11 — method sets + +Compile error: `T does not implement Stringer (method String has pointer +receiver)`. Method set of `T` = value-receiver methods only; method set of +`*T` = both. Fix: `var s Stringer = &T{}`. The *why*: interface values can +hold unaddressable copies (map elements, temporaries), so the compiler can't +conjure a `*T` from a `T` for you. Related asymmetry people forget: calling +`t.String()` directly on an addressable `t` works fine — the compiler inserts +`(&t)` — it's only interface *satisfaction* that's strict. +
diff --git a/10-gotchas/ex01-loopvar/main.go b/10-gotchas/ex01-loopvar/main.go new file mode 100644 index 0000000..87d686f --- /dev/null +++ b/10-gotchas/ex01-loopvar/main.go @@ -0,0 +1,20 @@ +// PREDICT the output before running. Then: go run . — and try with an older +// toolchain mentally: what did this print before Go 1.22? +package main + +import ( + "fmt" + "sync" +) + +func main() { + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println(i) + }() + } + wg.Wait() +} diff --git a/10-gotchas/ex02-deadlock/main.go b/10-gotchas/ex02-deadlock/main.go new file mode 100644 index 0000000..7575d79 --- /dev/null +++ b/10-gotchas/ex02-deadlock/main.go @@ -0,0 +1,10 @@ +// PREDICT: what happens? Then run it. Then fix it TWO different ways. +package main + +import "fmt" + +func main() { + ch := make(chan int) + ch <- 1 + fmt.Println(<-ch) +} diff --git a/10-gotchas/ex03-closed-channel/main.go b/10-gotchas/ex03-closed-channel/main.go new file mode 100644 index 0000000..413143c --- /dev/null +++ b/10-gotchas/ex03-closed-channel/main.go @@ -0,0 +1,14 @@ +// PREDICT each printed line, and what the last statement does. +package main + +import "fmt" + +func main() { + ch := make(chan int, 2) + ch <- 1 + close(ch) + fmt.Println(<-ch) + v, ok := <-ch + fmt.Println(v, ok) + close(ch) // ??? +} diff --git a/10-gotchas/ex04-nil-select/main.go b/10-gotchas/ex04-nil-select/main.go new file mode 100644 index 0000000..79caa3d --- /dev/null +++ b/10-gotchas/ex04-nil-select/main.go @@ -0,0 +1,16 @@ +// PREDICT: which case fires? What if b were nil too? +package main + +import "fmt" + +func main() { + var a chan int // nil + b := make(chan int, 1) + b <- 42 + select { + case v := <-a: + fmt.Println("a", v) + case v := <-b: + fmt.Println("b", v) + } +} diff --git a/10-gotchas/ex05-slice-aliasing/main.go b/10-gotchas/ex05-slice-aliasing/main.go new file mode 100644 index 0000000..ea9a3fc --- /dev/null +++ b/10-gotchas/ex05-slice-aliasing/main.go @@ -0,0 +1,17 @@ +// PREDICT both printed slices. Explain the difference between the two appends. +package main + +import "fmt" + +func main() { + a := []int{1, 2, 3, 4} + + b := a[:2] + b = append(b, 99) + fmt.Println("after append to a[:2] ->", a) + + a = []int{1, 2, 3, 4} + c := a[:2:2] + c = append(c, 100) + fmt.Println("after append to a[:2:2] ->", a) +} diff --git a/10-gotchas/ex06-defer-named-return/main.go b/10-gotchas/ex06-defer-named-return/main.go new file mode 100644 index 0000000..529f0f3 --- /dev/null +++ b/10-gotchas/ex06-defer-named-return/main.go @@ -0,0 +1,23 @@ +// PREDICT what f() and g() return, and the order of the deferred prints. +package main + +import "fmt" + +func f() (x int) { + defer func() { x++ }() + return 5 +} + +func g() int { + x := 5 + defer func() { x++ }() + return x +} + +func main() { + fmt.Println(f()) + fmt.Println(g()) + for i := 0; i < 3; i++ { + defer fmt.Println("deferred:", i) // when and in what order? + } +} diff --git a/10-gotchas/ex07-typed-nil/main.go b/10-gotchas/ex07-typed-nil/main.go new file mode 100644 index 0000000..66c0e4f --- /dev/null +++ b/10-gotchas/ex07-typed-nil/main.go @@ -0,0 +1,21 @@ +// PREDICT: which branch runs? This is the most-asked Go question in existence. +package main + +import "fmt" + +type MyErr struct{} + +func (*MyErr) Error() string { return "boom" } + +func do() error { + var e *MyErr // nil pointer + return e +} + +func main() { + if err := do(); err != nil { + fmt.Println("got error:", err) + } else { + fmt.Println("no error") + } +} diff --git a/10-gotchas/ex08-map-race/main.go b/10-gotchas/ex08-map-race/main.go new file mode 100644 index 0000000..ff203eb --- /dev/null +++ b/10-gotchas/ex08-map-race/main.go @@ -0,0 +1,22 @@ +// PREDICT: what happens? Can recover() save you? Run it a few times. +// Then run with: go run -race . +package main + +import ( + "fmt" + "time" +) + +func main() { + defer func() { + if r := recover(); r != nil { + fmt.Println("recovered:", r) // will this ever print? + } + }() + m := make(map[int]int) + for i := 0; i < 100; i++ { + go func(i int) { m[i] = i }(i) + } + time.Sleep(200 * time.Millisecond) + fmt.Println(len(m)) +} diff --git a/10-gotchas/ex09-waitgroup/main.go b/10-gotchas/ex09-waitgroup/main.go new file mode 100644 index 0000000..8e003c4 --- /dev/null +++ b/10-gotchas/ex09-waitgroup/main.go @@ -0,0 +1,22 @@ +// PREDICT: what are the possible outputs? What's the race here even if it +// "usually works"? Run with -race a few times. +package main + +import ( + "fmt" + "sync" +) + +func main() { + var wg sync.WaitGroup + count := 0 + for i := 0; i < 3; i++ { + go func() { + wg.Add(1) // <-- what's wrong with Add here? + defer wg.Done() + count++ + }() + } + wg.Wait() + fmt.Println("count:", count) +} diff --git a/10-gotchas/ex10-goroutine-leak/main.go b/10-gotchas/ex10-goroutine-leak/main.go new file mode 100644 index 0000000..51144c0 --- /dev/null +++ b/10-gotchas/ex10-goroutine-leak/main.go @@ -0,0 +1,32 @@ +// SPOT THE LEAK. Run it — it prints goroutine counts. Explain why the number +// grows, then fix it with a one-character-ish change. +package main + +import ( + "errors" + "fmt" + "runtime" + "time" +) + +func fetch() (string, error) { + ch := make(chan string) + go func() { + time.Sleep(50 * time.Millisecond) // pretend slow upstream + ch <- "body" + }() + select { + case body := <-ch: + return body, nil + case <-time.After(10 * time.Millisecond): // timeout fires first + return "", errors.New("timeout") + } +} + +func main() { + for i := 0; i < 100; i++ { + fetch() + } + time.Sleep(200 * time.Millisecond) + fmt.Println("goroutines still alive:", runtime.NumGoroutine()) // expected: 1 +} diff --git a/10-gotchas/ex11-method-set/main.go b/10-gotchas/ex11-method-set/main.go new file mode 100644 index 0000000..2a23b28 --- /dev/null +++ b/10-gotchas/ex11-method-set/main.go @@ -0,0 +1,15 @@ +// PREDICT: does this compile? If not, why not, and what's the minimal fix? +package main + +import "fmt" + +type Stringer interface{ String() string } + +type T struct{} + +func (t *T) String() string { return "T" } + +func main() { + var s Stringer = T{} + fmt.Println(s.String()) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dca87f --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Go SRE Interview Gym + +A monorepo of hands-on exercises covering what actually gets asked in Go +live-coding rounds for SRE roles. Each numbered folder is self-contained: + +- `main.go` — the assignment (in comments) + a scaffold with TODOs and a small + runnable demo +- `main_test.go` — the interviewer: tests that define "correct", including the + traps (goroutine leaks, races, boundary conditions) +- `README.md` — the deep dive: why the idiom exists, what breaks in production, + and the follow-up questions interviewers reach for + +## Setup + +Go ≥ 1.22 required (loop-variable semantics), any recent toolchain is fine — +built and verified against **go 1.27**. Pure stdlib, zero dependencies. + +## Workflow + +```bash +# pick an exercise, read the assignment in main.go, implement the TODOs +go test -race ./01-url-health-checker/ # -race ALWAYS. non-negotiable. + +# run everything you've solved so far +go test -race ./... + +# some exercises have benchmarks worth exploring +go test -bench=. -cpu=1,4,8 ./09-safe-counter/ +``` + +Interview-realistic rules: timer set to 30 min, no autocomplete, no LSP if +you're brave, and **narrate your reasoning out loud** — the talking is graded +as much as the code. + +## The ladder + +| # | Exercise | Core skill | Freq in interviews | +|---|----------|-----------|--------------------| +| 01 | url-health-checker | bounded concurrency, ctx, HTTP hygiene | ★★★★★ | +| 02 | worker-pool | channel closing protocol, WaitGroup | ★★★★★ | +| 03 | rate-limiter | lazy state, mutex, monotonic time | ★★★★☆ | +| 04 | log-topn | streaming, Scanner limits, map→sort | ★★★★☆ | +| 05 | lru-cache | map+list composite, O(1) design | ★★★★☆ | +| 06 | retry-backoff | jitter, ctx-aware sleep, error classes | ★★★☆☆ | +| 07 | fan-in | leak-free cancellation, nil-channel trick | ★★★☆☆ | +| 08 | graceful-shutdown | signals, Shutdown ctx inversion, k8s | ★★★☆☆ | +| 09 | safe-counter | mutex vs atomic vs sharding, -race | ★★★★☆ (as warm-up) | +| 10 | gotchas | "what does this print" trivia round | ★★★★★ | + +Suggested order: 09 → 02 → 01 → 03 → 05 → 04 → 06 → 07 → 08, with gotchas (10) +sprinkled daily. 09 first because it calibrates you on `-race`; 01/02 are the +highest-yield; 08 is the best *conversation* generator in on-sites. + +## Ground rules baked into the tests + +- Tests use timeouts to catch deadlocks and missing `close()` — a hanging test + is a failing test with extra steps. +- Several tests count goroutines or track peak concurrency with atomics — + read them after solving; test-writing techniques are interview material too. +- Everything must pass under `-race`. A green test that fails `-race` is red. + +## Note on the gotchas folder + +`10-gotchas/*` are predict-then-run programs: ex02 deadlocks on purpose, ex11 +doesn't compile on purpose. Run them individually (`go run ./10-gotchas/ex01-loopvar/`), +not as part of `go build ./...`. Answers are collapsed in `10-gotchas/README.md`. diff --git a/SOLUTIONS.md b/SOLUTIONS.md new file mode 100644 index 0000000..f3c180a --- /dev/null +++ b/SOLUTIONS.md @@ -0,0 +1,479 @@ +# Reference Solutions — SPOILERS + +Don't open this until you've fought the tests yourself. The struggle is the training. + +These are *reference* solutions (verified green under `go test -race`), not the only valid shape — a worker-pool answer to exercise 01 or a hand-rolled list in 05 is equally correct if the tests pass. + + +
01-url-health-checker + +```go +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +type Result struct { + URL string + Status int + Latency time.Duration + Err error +} + +func CheckAll(ctx context.Context, urls []string, limit int) []Result { + results := make([]Result, len(urls)) + sem := make(chan struct{}, limit) + var wg sync.WaitGroup + client := &http.Client{Timeout: 5 * time.Second} + + for i, u := range urls { + wg.Add(1) + go func(i int, u string) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + results[i] = Result{URL: u, Err: ctx.Err()} + return + } + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + results[i] = Result{URL: u, Err: err} + return + } + resp, err := client.Do(req) + if err != nil { + results[i] = Result{URL: u, Err: err, Latency: time.Since(start)} + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + results[i] = Result{URL: u, Status: resp.StatusCode, Latency: time.Since(start)} + }(i, u) + } + wg.Wait() + return results +} +``` +
+ + +
02-worker-pool + +```go +package main + +import ( + "fmt" + "sync" +) + +func Pool[T, R any](jobs <-chan T, workers int, fn func(T) R) <-chan R { + out := make(chan R) + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + for j := range jobs { + out <- fn(j) + } + }() + } + go func() { wg.Wait(); close(out) }() + return out +} +``` +
+ + +
03-rate-limiter + +```go +package main + +import ( + "fmt" + "math" + "sync" + "time" +) + +type Limiter struct { + mu sync.Mutex + tokens float64 + max float64 + rate float64 + last time.Time +} + +func NewLimiter(rate float64, burst int) *Limiter { + return &Limiter{tokens: float64(burst), max: float64(burst), rate: rate, last: time.Now()} +} + +func (l *Limiter) Allow() bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + l.tokens = math.Min(l.max, l.tokens+now.Sub(l.last).Seconds()*l.rate) + l.last = now + if l.tokens >= 1 { + l.tokens-- + return true + } + return false +} +``` +
+ + +
04-log-topn + +```go +package main + +import ( + "bufio" + "fmt" + "io" + "sort" + "strings" +) + +type KV struct { + Endpoint string + Count int +} + +func TopN(r io.Reader, n int) ([]KV, error) { + counts := make(map[string]int) + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 1024*1024), 1024*1024) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 3 { + continue + } + counts[fields[2]]++ + } + if err := sc.Err(); err != nil { + return nil, err + } + out := make([]KV, 0, len(counts)) + for k, v := range counts { + out = append(out, KV{k, v}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count }) + if len(out) > n { + out = out[:n] + } + return out, nil +} +``` +
+ + +
05-lru-cache + +```go +package main + +import ( + "container/list" + "fmt" + "sync" +) + +type entry struct { + key string + val any +} + +type LRU struct { + mu sync.Mutex + cap int + ll *list.List + items map[string]*list.Element +} + +func NewLRU(capacity int) *LRU { + return &LRU{cap: capacity, ll: list.New(), items: make(map[string]*list.Element)} +} + +func (c *LRU) Get(key string) (any, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + c.ll.MoveToFront(el) + return el.Value.(*entry).val, true + } + return nil, false +} + +func (c *LRU) Put(key string, val any) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + el.Value.(*entry).val = val + c.ll.MoveToFront(el) + return + } + if c.ll.Len() >= c.cap { + back := c.ll.Back() + c.ll.Remove(back) + delete(c.items, back.Value.(*entry).key) + } + c.items[key] = c.ll.PushFront(&entry{key, val}) +} + +func (c *LRU) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.ll.Len() +} +``` +
+ + +
06-retry-backoff + +```go +package main + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "time" +) + +var ErrRetryable = errors.New("retryable") + +func Retry(ctx context.Context, attempts int, base time.Duration, fn func(context.Context) error) error { + var err error + for i := 0; i < attempts; i++ { + if err = fn(ctx); err == nil { + return nil + } + if !errors.Is(err, ErrRetryable) { + return err + } + if i == attempts-1 { + break + } + backoff := base << i + sleep := time.Duration(rand.Int64N(int64(backoff))) + select { + case <-time.After(sleep): + case <-ctx.Done(): + return errors.Join(ctx.Err(), err) + } + } + return fmt.Errorf("after %d attempts: %w", attempts, err) +} +``` +
+ + +
07-fan-in + +```go +package main + +import ( + "context" + "fmt" + "sync" +) + +func Merge[T any](ctx context.Context, chs ...<-chan T) <-chan T { + out := make(chan T) + var wg sync.WaitGroup + wg.Add(len(chs)) + for _, ch := range chs { + go func(ch <-chan T) { + defer wg.Done() + for { + select { + case v, ok := <-ch: + if !ok { + return + } + select { + case out <- v: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } + }(ch) + } + go func() { wg.Wait(); close(out) }() + return out +} +``` +
+ + +
08-graceful-shutdown + +```go +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" +) + +func NewServer(addr string, h http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: h, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } +} + +func Run(ctx context.Context, srv *http.Server, drainTimeout time.Duration) error { + errCh := make(chan error, 1) + go func() { errCh <- srv.ListenAndServe() }() + + select { + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + case <-ctx.Done(): + } + + shutCtx, cancel := context.WithTimeout(context.Background(), drainTimeout) + defer cancel() + if err := srv.Shutdown(shutCtx); err != nil { + return fmt.Errorf("drain: %w", err) + } + if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} +``` +
+ + +
09-safe-counter + +```go +package main + +import ( + "fmt" + "hash/fnv" + "sync" + "sync/atomic" +) + +type MutexCounter struct { + mu sync.Mutex + m map[string]int64 +} + +func NewMutexCounter() *MutexCounter { return &MutexCounter{m: make(map[string]int64)} } + +func (c *MutexCounter) Inc(key string) { + c.mu.Lock() + c.m[key]++ + c.mu.Unlock() +} + +func (c *MutexCounter) Get(key string) int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.m[key] +} + +func (c *MutexCounter) Snapshot() map[string]int64 { + c.mu.Lock() + defer c.mu.Unlock() + cp := make(map[string]int64, len(c.m)) + for k, v := range c.m { + cp[k] = v + } + return cp +} + +type AtomicCounter struct { + n atomic.Int64 +} + +func NewAtomicCounter() *AtomicCounter { return &AtomicCounter{} } +func (c *AtomicCounter) Inc() { c.n.Add(1) } +func (c *AtomicCounter) Get() int64 { return c.n.Load() } + +const shards = 16 + +type shard struct { + mu sync.Mutex + m map[string]int64 +} + +type ShardedCounter struct { + s [shards]shard +} + +func NewShardedCounter() *ShardedCounter { + c := &ShardedCounter{} + for i := range c.s { + c.s[i].m = make(map[string]int64) + } + return c +} + +func (c *ShardedCounter) shardFor(key string) *shard { + h := fnv.New32a() + h.Write([]byte(key)) + return &c.s[h.Sum32()%shards] +} + +func (c *ShardedCounter) Inc(key string) { + s := c.shardFor(key) + s.mu.Lock() + s.m[key]++ + s.mu.Unlock() +} + +func (c *ShardedCounter) Get(key string) int64 { + s := c.shardFor(key) + s.mu.Lock() + defer s.mu.Unlock() + return s.m[key] +} + +func (c *ShardedCounter) Snapshot() map[string]int64 { + out := make(map[string]int64) + for i := range c.s { + c.s[i].mu.Lock() + for k, v := range c.s[i].m { + out[k] = v + } + c.s[i].mu.Unlock() + } + return out +} +``` +
diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c272409 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gosre-exercises + +go 1.27.0