Skip to content

Commit 838be4f

Browse files
committed
fix(flowcontrol/bench): restore L backpressure in benchDetector self-heal
The previous detector fix (f527a2b) stopped the L=1 hang but over-corrected: it reclaimed the prior grant on every Saturation call, pinning inFlight at <=1 so the mock never reported saturated for any limit>=1. That silently removed the W>L backpressure the PerformanceMatrix sweep exists to measure -- every L behaved as free-flow. Restore the original reserve-in-read accounting (inFlight accumulates to L under load and Saturation returns 1.0 once exceeded), and self-heal only the genuine leak source: the processor's 1ms dispatch ticker reserves a permit on empty-queue idle cycles that never dispatch and so are never Released. Distinguish leaked idle grants from real load saturation via releaseCount -- while releases are flowing the saturation is genuine; two consecutive saturated reads with zero releases between them mean the outstanding grants are leaked idle permits, so reclaim one. This keeps L=1 from latching (no hang) without capping accumulation. Known approximation: under severe scheduler starvation a saturated band may over-admit by ~1; this only perturbs the d/s numbers, never fails (the matrix does not assert on dispatch outcomes). Tests: add TestBenchDetector_SaturatesAtLimit (the backpressure assertion whose absence let the regression through); reframe the no-leak test as a no-permanent- latch/self-heal guard; replace the pendingGrant-specific test with a release-recovers-capacity test. All deterministic, pass under -race. Verified: L=1 vs L=100 throughput now separates by ~10x (was identical); full bench smoke completes with no hang.
1 parent b322a55 commit 838be4f

2 files changed

Lines changed: 99 additions & 39 deletions

File tree

pkg/epp/flowcontrol/benchmark/detector_test.go

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,49 @@ import (
2323

2424
// TestBenchDetector_NoLeakOnNonDispatchingCycles is the regression guard for the leak that made
2525
// matrix coordinates at L=1 hang for the full request TTL: the processor's dispatchCycle evaluates
26-
// Saturation once per cycle but may not dispatch (empty queue, or head-of-line gating), so a
27-
// detector that reserved a permit per call without reclaiming it would latch to saturated forever.
26+
// Saturation on a 1ms ticker even when the queue is empty, so those grants never dispatch and are
27+
// never Released. The detector may report saturated briefly but must self-heal -- it must never
28+
// latch, so grants keep recurring and no saturated run exceeds the stuck threshold.
2829
func TestBenchDetector_NoLeakOnNonDispatchingCycles(t *testing.T) {
2930
d := &benchDetector{}
3031
d.concurrencyLimit.Store(1)
3132

32-
// Simulate many dispatch cycles that evaluate saturation but never dispatch (hence never Release).
33-
// A leaking detector latches at >= 1.0.
33+
grants, run, maxRun := 0, 0, 0
3434
for i := 0; i < 100; i++ {
35+
if d.Saturation(context.Background(), nil) < 1.0 {
36+
grants++
37+
run = 0
38+
} else {
39+
run++
40+
if run > maxRun {
41+
maxRun = run
42+
}
43+
}
44+
}
45+
if grants == 0 {
46+
t.Fatal("detector latched saturated across all idle cycles (permanent leak)")
47+
}
48+
if maxRun > stuckReadThreshold {
49+
t.Fatalf("idle saturated run = %d, want <= %d (self-heal should reclaim)", maxRun, stuckReadThreshold)
50+
}
51+
}
52+
53+
// TestBenchDetector_SaturatesAtLimit is the assertion whose absence let the backpressure regression
54+
// through: with the limit reached by in-flight (un-Released) grants, the next Saturation must report
55+
// saturated. This is the W>L backpressure the PerformanceMatrix sweep exists to measure.
56+
func TestBenchDetector_SaturatesAtLimit(t *testing.T) {
57+
d := &benchDetector{}
58+
d.concurrencyLimit.Store(2)
59+
60+
// Two grants whose requests are still in flight (not yet Released) occupy both slots.
61+
for i := 0; i < 2; i++ {
3562
if got := d.Saturation(context.Background(), nil); got >= 1.0 {
36-
t.Fatalf("detector latched saturated on idle cycle %d: got %v, want < 1.0", i, got)
63+
t.Fatalf("grant %d within limit must not be saturated: got %v", i, got)
3764
}
3865
}
66+
if got := d.Saturation(context.Background(), nil); got < 1.0 {
67+
t.Fatalf("over-limit Saturation must report saturated: got %v, want >= 1.0", got)
68+
}
3969
}
4070

4171
// TestBenchDetector_GrantReleaseBalanced verifies the common path: a cycle grants
@@ -57,21 +87,27 @@ func TestBenchDetector_GrantReleaseBalanced(t *testing.T) {
5787
}
5888
}
5989

60-
// TestBenchDetector_OverlappingGrantsConserveCount exercises the race-prone interleaving
61-
// deterministically: a second cycle grants before the first dispatch's worker releases, then both
62-
// releases arrive. The shared CompareAndSwap must free each permit exactly once -- no leak, no
63-
// underflow.
64-
func TestBenchDetector_OverlappingGrantsConserveCount(t *testing.T) {
90+
// TestBenchDetector_ReleaseRecoversCapacity verifies the steady-state load path: grants fill the
91+
// limit and report saturated, Releases free the slots back to zero, and freed capacity is grantable
92+
// again -- the conserve-count property under sustained dispatch.
93+
func TestBenchDetector_ReleaseRecoversCapacity(t *testing.T) {
6594
d := &benchDetector{}
66-
d.concurrencyLimit.Store(1)
95+
d.concurrencyLimit.Store(2)
6796

68-
d.Saturation(context.Background(), nil) // grant #1 (dispatched, release pending)
69-
d.Saturation(context.Background(), nil) // reclaims #1, grants #2 (dispatched)
70-
d.Release() // frees the outstanding grant
71-
d.Release() // stale: CAS fails, no-op
97+
d.Saturation(context.Background(), nil) // grant 1 (in flight)
98+
d.Saturation(context.Background(), nil) // grant 2 (in flight) -> at limit
99+
if got := d.Saturation(context.Background(), nil); got < 1.0 {
100+
t.Fatalf("at-limit Saturation must be saturated: got %v, want >= 1.0", got)
101+
}
72102

103+
d.Release() // both in-flight requests complete
104+
d.Release()
73105
if got := d.inFlight.Load(); got != 0 {
74-
t.Fatalf("inFlight = %d after balanced grants/releases, want 0", got)
106+
t.Fatalf("inFlight = %d after releasing all grants, want 0", got)
107+
}
108+
109+
if got := d.Saturation(context.Background(), nil); got >= 1.0 {
110+
t.Fatalf("grant after releases must not be saturated: got %v", got)
75111
}
76112
}
77113

pkg/epp/flowcontrol/benchmark/helpers_test.go

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -89,56 +89,80 @@ type testDetector interface {
8989
// parking goroutines. The processor's dispatch loop calls Saturation once per cycle and then
9090
// dispatches at most one item; the benchmark worker calls Release after that item is dispatched.
9191
//
92-
// A permit is reserved optimistically inside Saturation and freed by whichever happens first: the
93-
// worker's Release (the cycle dispatched) or the next Saturation call (the cycle did not).
94-
// Reclaiming the previous, unconsumed grant on the next call keeps idle or head-of-line-gated
95-
// cycles from leaking permits -- a leak that would otherwise permanently saturate the detector at
96-
// low limits (e.g. L=1, where one leaked permit deadlocks every request until its TTL expires).
92+
// Saturation optimistically reserves a permit (inFlight++); Release frees it. Under the matrix's
93+
// W>L load the queue is always full, so every grant leads to a dispatch and a Release: inFlight
94+
// accumulates to L and Saturation reports 1.0 once exceeded, which is the backpressure the matrix
95+
// measures.
9796
//
98-
// The dispatch loop is single-threaded, so at most one grant is outstanding at a time; pendingGrant
99-
// tracks it. Release and the reclaim path share a CompareAndSwap on pendingGrant, so each grant is
100-
// freed exactly once even when the two race.
97+
// The complication: the processor also runs dispatchCycle on a 1ms ticker when the queue is EMPTY
98+
// (notably at startup). Those idle grants never dispatch and so are never Released; a naive detector
99+
// would latch saturated forever and deadlock every request until its TTL (the L=1 hang). We
100+
// distinguish leaked idle grants from genuine load saturation with releaseCount: while releases are
101+
// flowing the saturation is real; if two consecutive saturated reads see zero releases between them
102+
// the outstanding grants are leaked idle permits, so we reclaim one. The 1.0/0.99 split sits below
103+
// usagelimits.DefaultPolicy's 1.0 ceiling, so a grant never trips head-of-line gating.
104+
//
105+
// stuckReads and lastReleaseCount are touched only by the single-threaded dispatch loop (Saturation)
106+
// and so need no synchronization; inFlight and releaseCount are atomic (Release runs on workers).
101107
type benchDetector struct {
102108
flowcontrol.SaturationDetector
103109
concurrencyLimit atomic.Int64
104-
// _ prevents false sharing between atomic counters on multicore CPU cache lines.
110+
// _ prevents false sharing between concurrencyLimit and the hot atomic counters below.
105111
_ [48]byte
106112
inFlight atomic.Int64
107-
pendingGrant atomic.Int64
113+
releaseCount atomic.Int64
114+
115+
stuckReads int
116+
lastReleaseCount int64
108117
}
109118

110-
// Release frees the permit held by the dispatch that just completed. If the next Saturation call
111-
// already reclaimed it, the CompareAndSwap fails and this is a no-op, preventing a double free.
119+
// stuckReadThreshold is the number of consecutive saturated reads with zero intervening releases
120+
// after which the detector treats the outstanding grants as leaked idle permits and reclaims one.
121+
// >1 so that a single slow Release under load does not cause a spurious reclaim.
122+
const stuckReadThreshold = 2
123+
124+
// Release frees the permit held by the dispatch that just completed.
112125
func (d *benchDetector) Release() {
113126
if d.concurrencyLimit.Load() <= 0 {
114127
return
115128
}
116-
if d.pendingGrant.CompareAndSwap(1, 0) {
117-
d.inFlight.Add(-1)
118-
}
129+
d.inFlight.Add(-1)
130+
d.releaseCount.Add(1)
119131
}
120132

121-
// Saturation reclaims the previous cycle's grant if no dispatch consumed it, then optimistically
122-
// reserves a permit for this cycle.
133+
// Saturation reserves a permit for this cycle, reporting saturated once the limit is exceeded and
134+
// self-healing leaked idle grants (see the type doc).
123135
func (d *benchDetector) Saturation(ctx context.Context, candidates []fwkdl.Endpoint) float64 {
124136
limit := d.concurrencyLimit.Load()
125137
if limit <= 0 {
126138
return 0.0 // Free-flow.
127139
}
128140

129-
// Reclaim a grant left unconsumed by a previous, non-dispatching cycle.
130-
if d.pendingGrant.CompareAndSwap(1, 0) {
131-
d.inFlight.Add(-1)
132-
}
133-
134141
// Optimistically reserve a permit for this cycle.
135142
if d.inFlight.Add(1) <= limit {
136-
d.pendingGrant.Store(1)
143+
d.stuckReads = 0
137144
return 0.99 // Return < 1.0 so the dispatcher proceeds.
138145
}
139146

140147
// Capacity exceeded; roll back the speculative reservation.
141148
d.inFlight.Add(-1)
149+
150+
rc := d.releaseCount.Load()
151+
if rc != d.lastReleaseCount {
152+
// Releases are flowing: this is genuine load saturation, not a leak.
153+
d.lastReleaseCount = rc
154+
d.stuckReads = 0
155+
return 1.0
156+
}
157+
158+
// No releases since the last saturated read. After a couple of these the outstanding grants must
159+
// be leaked idle permits (an empty-queue dispatch tick reserved but never dispatched), so reclaim
160+
// one to keep the detector from latching.
161+
d.stuckReads++
162+
if d.stuckReads >= stuckReadThreshold {
163+
d.inFlight.Add(-1)
164+
d.stuckReads = 0
165+
}
142166
return 1.0 // Saturated - forces the Flow Control layer to hold the item.
143167
}
144168

0 commit comments

Comments
 (0)