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,2 @@
|
|||||||
|
.idea/
|
||||||
|
.junie/
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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?")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) // ???
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
Reference in New Issue
Block a user