gw-06 step 01 — Power-of-two-choices load balancing + outlier ejection
Goal
Implement P2C load balancing with EWMA latency scoring and passive outlier ejection, then show it routes around a slow endpoint where round-robin does not.
Code — src/go/balancer.go
package lb
import (
"math/rand"
"sync"
"sync/atomic"
"time"
)
type Endpoint struct {
Addr string
inflight atomic.Int64
ewmaNs atomic.Int64 // EWMA of latency in ns
ejectedUntil atomic.Int64 // unix-nano; 0 = healthy
fails atomic.Int64
}
// score: lower is better. Combine in-flight and latency EWMA.
func (e *Endpoint) score() float64 {
return float64(e.inflight.Load()+1) * float64(e.ewmaNs.Load()+1)
}
func (e *Endpoint) ejected(now int64) bool { return e.ejectedUntil.Load() > now }
type P2C struct {
mu sync.RWMutex
eps []*Endpoint
rnd *rand.Rand
}
func NewP2C(addrs []string) *P2C {
b := &P2C{rnd: rand.New(rand.NewSource(time.Now().UnixNano()))}
for _, a := range addrs {
b.eps = append(b.eps, &Endpoint{Addr: a})
}
return b
}
// Pick returns an endpoint via power-of-two-choices among healthy ones.
func (b *P2C) Pick() *Endpoint {
b.mu.RLock()
defer b.mu.RUnlock()
now := time.Now().UnixNano()
healthy := make([]*Endpoint, 0, len(b.eps))
for _, e := range b.eps {
if !e.ejected(now) {
healthy = append(healthy, e)
}
}
if len(healthy) == 0 {
healthy = b.eps // panic mode: everyone ejected -> use all (don't black-hole)
}
if len(healthy) == 1 {
return healthy[0]
}
i := b.rnd.Intn(len(healthy))
j := b.rnd.Intn(len(healthy) - 1)
if j >= i {
j++ // distinct second choice
}
a, c := healthy[i], healthy[j]
if a.score() <= c.score() {
return a
}
return c
}
// Observe records the result of a call for EWMA + ejection.
func (b *P2C) Observe(e *Endpoint, latency time.Duration, ok bool) {
// EWMA: new = alpha*sample + (1-alpha)*old
const alpha = 0.2
old := e.ewmaNs.Load()
sample := latency.Nanoseconds()
e.ewmaNs.Store(int64(alpha*float64(sample) + (1-alpha)*float64(old)))
if ok {
e.fails.Store(0)
return
}
// Consecutive-failure ejection with growing cool-down.
n := e.fails.Add(1)
if n >= 5 {
cool := time.Duration(n) * 2 * time.Second
e.ejectedUntil.Store(time.Now().Add(cool).UnixNano())
}
}
The experiment — P2C vs round-robin under a slow endpoint
setup: 5 endpoints; endpoint #3 injected with 10x latency (a "gray
failure" — up but slow).
round-robin: 1/5 of requests hit #3 and eat its 10x latency; the p99 is
dominated by #3. Throughput drops because in-flight piles up on #3.
P2C + EWMA: as #3's EWMA latency climbs, it almost always loses the
"two choices" comparison, so it gets a shrinking share. The p99 stays
near the healthy endpoints'. If #3 starts erroring, outlier ejection
removes it entirely.
Tasks
- Implement
P2C,Observe, EWMA, and ejection. - Stand up 5 mock endpoints; inject 10× latency on one. Drive load and compare the request distribution and p99 for round-robin vs P2C.
- Flip the slow endpoint to erroring; confirm outlier ejection removes it within ~5 failures and probes it back after the cool-down.
- Add the panic threshold: when >50% would be ejected, stop ejecting (a fleet-wide brown-out must not black-hole all traffic).
Acceptance
- P2C sends a shrinking share to the slow endpoint; p99 stays near the healthy set's. Round-robin's p99 is dragged by the slow one.
- Outlier ejection removes a failing endpoint and restores it after cool-down; the panic threshold prevents ejecting everyone.
Discussion prompts
- Why is EWMA (not the last latency) the right signal? What does a too-
high vs too-low
alphado? - Why does P2C beat round-robin so much for almost no extra cost? (The "power of two choices" exponential max-load result.)
- With per-event-loop state (gw-04), each loop sees only its own in-flight counts. Is per-loop P2C good enough, or do you need shared counters? Argue it.