Initial commit: Go SRE interview exercises

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
This commit is contained in:
2026-08-28 22:18:09 +02:00
co-authored by Claude Fable 5
commit 7219eb5a23
43 changed files with 2865 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# 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)
}
}