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
+2
View File
@@ -0,0 +1,2 @@
.idea/
.junie/
+66
View File
@@ -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.
+53
View File
@@ -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)
}
}
+108
View File
@@ -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?")
}
}
+54
View File
@@ -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.
+61
View File
@@ -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)
}
}
+100
View File
@@ -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")
}
}
+48
View File
@@ -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.
+45
View File
@@ -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)
}
}
+71
View File
@@ -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)
}
}
+54
View File
@@ -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.
+52
View File
@@ -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)
}
+65
View File
@@ -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)
}
}
+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())
}
}
+58
View File
@@ -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.
+53
View File
@@ -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)
}
+88
View File
@@ -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)
}
}
+65
View File
@@ -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.
+49
View File
@@ -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)
}
}
+105
View File
@@ -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):
}
}
}
+66
View File
@@ -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.
+69
View File
@@ -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")
}
+140
View File
@@ -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?")
}
}
+54
View File
@@ -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.
+131
View File
@@ -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())
}
+129
View File
@@ -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()
}
})
}
+129
View File
@@ -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)
<details><summary><b>ex01 — loop variable capture</b></summary>
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.
</details>
<details><summary><b>ex02 — unbuffered send deadlock</b></summary>
`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.
</details>
<details><summary><b>ex03 — closed channel semantics</b></summary>
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.
</details>
<details><summary><b>ex04 — nil channel in select</b></summary>
`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).
</details>
<details><summary><b>ex05 — slice aliasing via append</b></summary>
`[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.
</details>
<details><summary><b>ex06 — defer + named returns</b></summary>
`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`.
</details>
<details><summary><b>ex07 — typed nil interface</b></summary>
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.
</details>
<details><summary><b>ex08 — concurrent map writes</b></summary>
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.
</details>
<details><summary><b>ex09 — WaitGroup Add placement</b></summary>
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).
</details>
<details><summary><b>ex10 — goroutine leak on timeout</b></summary>
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.
</details>
<details><summary><b>ex11 — method sets</b></summary>
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.
</details>
+20
View File
@@ -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()
}
+10
View File
@@ -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)
}
+14
View File
@@ -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) // ???
}
+16
View File
@@ -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)
}
}
+17
View File
@@ -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)
}
@@ -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?
}
}
+21
View File
@@ -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")
}
}
+22
View File
@@ -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))
}
+22
View File
@@ -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)
}
+32
View File
@@ -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
}
+15
View File
@@ -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())
}
+66
View File
@@ -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`.
+479
View File
@@ -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.
<details><summary><b>01-url-health-checker</b></summary>
```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
}
```
</details>
<details><summary><b>02-worker-pool</b></summary>
```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
}
```
</details>
<details><summary><b>03-rate-limiter</b></summary>
```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
}
```
</details>
<details><summary><b>04-log-topn</b></summary>
```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
}
```
</details>
<details><summary><b>05-lru-cache</b></summary>
```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()
}
```
</details>
<details><summary><b>06-retry-backoff</b></summary>
```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)
}
```
</details>
<details><summary><b>07-fan-in</b></summary>
```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
}
```
</details>
<details><summary><b>08-graceful-shutdown</b></summary>
```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
}
```
</details>
<details><summary><b>09-safe-counter</b></summary>
```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
}
```
</details>
+3
View File
@@ -0,0 +1,3 @@
module gosre-exercises
go 1.27.0