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:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user