gw-06 step 02 — Adaptive concurrency limiting
Goal
Replace a fixed concurrency limit with one that adapts from observed
latency, so the gateway keeps latency bounded under overload by shedding
at admission — Netflix's concurrency-limits idea in miniature.
Theory in one paragraph
By Little's Law, in-flight L = λ × W. While the system is below its
knee, raising the in-flight limit raises throughput at ~flat latency.
Past the knee, latency rises but throughput doesn't — you're just
queueing. So: track the minimum latency ever seen (RTT_noload, the
uncontended baseline) and the current latency. If current ≈ minimum,
there's headroom — grow the limit. If current >> minimum, a queue is
forming — shrink it. Reject requests over the limit immediately (fast
503) instead of letting them queue.
Code — src/go/limiter.go
package limit
import (
"math"
"sync"
"sync/atomic"
"time"
)
// Gradient2-style adaptive limiter (simplified). limit grows when
// current latency is near the no-load minimum, shrinks when it climbs.
type Adaptive struct {
mu sync.Mutex
limit float64
minRTT time.Duration // long-window minimum (no-load baseline)
inflight atomic.Int64
minLimit, maxLimit float64
}
func New() *Adaptive {
return &Adaptive{limit: 20, minLimit: 1, maxLimit: 2000, minRTT: time.Hour}
}
// Acquire admits a request if under the current limit. Returns a release
// func and true, or false if the request must be shed.
func (a *Adaptive) Acquire() (release func(rtt time.Duration, dropped bool), ok bool) {
if float64(a.inflight.Add(1)) > a.curLimit() {
a.inflight.Add(-1)
return nil, false // SHED at admission — bounded latency under load
}
start := time.Now()
return func(rtt time.Duration, dropped bool) {
a.inflight.Add(-1)
if rtt == 0 {
rtt = time.Since(start)
}
a.update(rtt, dropped)
}, true
}
func (a *Adaptive) curLimit() float64 {
a.mu.Lock()
defer a.mu.Unlock()
return a.limit
}
// update adjusts the limit from the latest sample (the control loop).
func (a *Adaptive) update(rtt time.Duration, dropped bool) {
a.mu.Lock()
defer a.mu.Unlock()
if rtt < a.minRTT {
a.minRTT = rtt // track the uncontended baseline
}
if dropped {
a.limit = math.Max(a.minLimit, a.limit*0.9) // multiplicative decrease on error
return
}
// gradient = noload / current, clamped to [0.5, 1.0]
gradient := math.Max(0.5, math.Min(1.0,
float64(a.minRTT)/float64(rtt)))
// queue size headroom allows additive growth when gradient ~ 1.
newLimit := a.limit*gradient + math.Sqrt(a.limit) // headroom term
a.limit = math.Max(a.minLimit, math.Min(a.maxLimit, newLimit))
}
The experiment — fixed vs adaptive under a latency cliff
phase 1 (healthy): origin RTT ~5ms. Both fixed(=200) and adaptive serve
fine; adaptive settles near the throughput knee.
phase 2 (dependency slows to 100ms):
FIXED limit 200: in-flight rushes to 200, queue builds, latency =
200 × 100ms / arrival ... explodes into seconds; clients time out;
retries pile on (step 03).
ADAPTIVE: current RTT (100ms) >> minRTT (5ms) -> gradient ~0.05 ->
limit collapses toward minLimit; excess is shed with fast 503s;
served requests keep ~bounded latency. The system stays in control.
Tasks
- Implement
Adaptive; wrap the endpoint call (Acquire→ call origin →release(rtt, dropped)). - Run the two-phase experiment; plot served-latency p99 and shed-rate for fixed vs adaptive across the latency cliff.
- Show that adaptive needs no magic number: it finds a reasonable limit from latency alone, and recovers (grows back) when the dependency heals.
Acceptance
- Under the latency cliff, the fixed limiter's served latency explodes while the adaptive limiter holds latency ~bounded by shedding.
- The adaptive limit shrinks during overload and grows back on recovery, with no hand-tuned threshold.
Discussion prompts
- Why shed at admission rather than queue? (Bounded latency; a queued request that the client already timed out on is pure waste — "doomed work.")
- How is this analogous to TCP congestion control (AIMD, RTT-based)?
- Where does this sit relative to the connection pool (gw-04)? (Pool acquisition is itself a concurrency limit; reconcile the two so they don't fight.)