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,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?")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user