Skip to content

Commit 00bad6f

Browse files
committed
fix(inflightload): prevent in-flight counter underflow on endpoint flap
The per-endpoint in-flight request/token counters are a +1/-1 ledger keyed only by NamespacedName. PreRequest increments via the tracker map; release (OnEvicted/releaseTokensEarly) re-looked-up the counter by endpoint ID and decremented whatever instance was currently mapped. When an endpoint flaps (NotReady -> EventDelete drops the counter -> rejoins under the same NamespacedName -> new traffic recreates it), an in-flight request's release landed on the recreated counter instead of the one it incremented, driving it negative. This surfaced as negative pool saturation (observed ~ -1.4), amplified when a pod crashed while holding many in-flight requests. The existing registeredEndpoints pointer-identity guard only drops out-of-order delete events for an already-replaced pointer; it does not cover the normal flap sequence, so the underflow persisted. - Capture a direct pointer to the exact *atomic.Int64 each request increments, and decrement that instance on release. A stale release after a flap now lands harmlessly on the orphaned counter; the live (recreated) counter only ever sees its own request's +1/-1. - Remove the now-unnecessary addIfPresent/decIfPresent 'is it present' guard. - Add a decrementClamped CAS helper (mirroring the canonical predictedlatency.decrementEndpointCounter primitive) that floors decrements at 0 as defense-in-depth, so saturation can never go negative even under a future regression. - Add regression tests for the delete-then-recreate flap and the crash-with-high-load drain (both fail without the fix), plus coverage for the stale-delete guard. Tests use the realistic AddOrUpdate-then-Delete ordering. Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 61c3aca commit 00bad6f

2 files changed

Lines changed: 219 additions & 43 deletions

File tree

pkg/epp/framework/plugins/requestcontrol/dataproducer/inflightload/producer.go

Lines changed: 57 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -132,58 +132,42 @@ type InFlightLoadProducer struct {
132132
// can race safely: whichever swaps first does the decrement, the other
133133
// sees 0 and is a no-op.
134134
type addedTokensEntry struct {
135-
endpointID string
136-
tokens atomic.Int64
137-
tokenTracker *concurrencyTracker
138-
requestTracker *concurrencyTracker
135+
tokens atomic.Int64
136+
// tokenCounter and requestCounter point at the exact tracker counter instances this request
137+
// incremented in PreRequest. A release decrements these instances directly, so it always lands
138+
// on the counter that received the increment. If the endpoint flaps (delete + recreate under the
139+
// same NamespacedName) between increment and release, the captured instance is the orphaned
140+
// counter; decrementing it leaves the live counter untouched.
141+
tokenCounter *atomic.Int64
142+
requestCounter *atomic.Int64
139143
requests atomic.Int32
140144
}
141145

142146
var _ fwkplugin.EvictableStateData = (*addedTokensEntry)(nil)
143147

144-
// Clone returns a distinct copy of the entry with the current atomic values.
145-
// The tracker references remain shared, but the cloned state object itself is
146-
// independent so later mutation or eviction of the clone does not alias the
147-
// original entry.
148+
// Clone returns a distinct copy of the entry with the current atomic values. The counter-instance
149+
// pointers stay shared (the clone releases against the same counters the original incremented), but
150+
// the cloned state object itself is independent so later mutation or eviction of the clone does not
151+
// alias the original entry.
148152
func (e *addedTokensEntry) Clone() fwkplugin.StateData {
149153
if e == nil {
150154
return nil
151155
}
152156
clone := &addedTokensEntry{
153-
endpointID: e.endpointID,
154-
tokenTracker: e.tokenTracker,
155-
requestTracker: e.requestTracker,
157+
tokenCounter: e.tokenCounter,
158+
requestCounter: e.requestCounter,
156159
}
157160
clone.tokens.Store(e.tokens.Load())
158161
clone.requests.Store(e.requests.Load())
159162
return clone
160163
}
161164

162-
// addIfPresent applies delta only when the endpoint is still tracked.
163-
// This avoids recreating a deleted endpoint with a negative in-flight count
164-
// during delayed eviction cleanup.
165-
func (t *concurrencyTracker) addIfPresent(endpointID string, delta int64) {
166-
t.mu.Lock()
167-
defer t.mu.Unlock()
168-
169-
counter, ok := t.counts[endpointID]
170-
if !ok {
171-
return
172-
}
173-
counter.Add(delta)
174-
}
175-
176-
// decIfPresent decrements the endpoint only when it is still tracked.
177-
func (t *concurrencyTracker) decIfPresent(endpointID string) {
178-
t.addIfPresent(endpointID, -1)
179-
}
180-
181165
func (e *addedTokensEntry) OnEvicted(_ string, _ fwkplugin.StateKey) {
182166
if t := e.tokens.Swap(0); t != 0 {
183-
e.tokenTracker.addIfPresent(e.endpointID, -t)
167+
decrementClamped(e.tokenCounter, t)
184168
}
185169
if e.requests.Swap(0) != 0 {
186-
e.requestTracker.decIfPresent(e.endpointID)
170+
decrementClamped(e.requestCounter, 1)
187171
}
188172
}
189173

@@ -374,20 +358,19 @@ func (p *InFlightLoadProducer) PreRequest(ctx context.Context, request *fwksched
374358
continue
375359
}
376360
eid := endpoint.GetMetadata().NamespacedName.String()
377-
p.requestTracker.inc(eid)
361+
requestCounter := p.requestTracker.inc(eid)
378362

379363
// Compute the uncached prompt portion this endpoint must actually compute.
380364
// Prefer the prefix producer's view (real tokens) when available so the
381365
// match-length and the input length are in the same units; fall back to
382366
// the (estimated) input tokens otherwise.
383367
tokens := p.estimateRequestTokens(endpoint, inputTokens)
384368

385-
p.tokenTracker.add(eid, tokens)
369+
tokenCounter := p.tokenTracker.add(eid, tokens)
386370

387371
entry := &addedTokensEntry{
388-
endpointID: eid,
389-
tokenTracker: p.tokenTracker,
390-
requestTracker: p.requestTracker,
372+
tokenCounter: tokenCounter,
373+
requestCounter: requestCounter,
391374
}
392375
entry.tokens.Store(tokens)
393376
entry.requests.Store(1)
@@ -502,7 +485,7 @@ func (p *InFlightLoadProducer) releaseTokensEarly(endpoint fwksched.Endpoint, re
502485
key := fwkplugin.StateKey(addedTokensKey(eid, profileName))
503486
if entry, err := fwkplugin.ReadPluginStateKey[*addedTokensEntry](p.PluginState, request.RequestID, key); err == nil {
504487
if t := entry.tokens.Swap(0); t != 0 {
505-
entry.tokenTracker.addIfPresent(entry.endpointID, -t)
488+
decrementClamped(entry.tokenCounter, t)
506489
}
507490
}
508491
}
@@ -635,31 +618,62 @@ func (t *concurrencyTracker) snapshot() map[string]int64 {
635618
return result
636619
}
637620

638-
func (t *concurrencyTracker) inc(endpointID string) {
639-
t.add(endpointID, 1)
621+
func (t *concurrencyTracker) inc(endpointID string) *atomic.Int64 {
622+
return t.add(endpointID, 1)
640623
}
641624

642-
func (t *concurrencyTracker) add(endpointID string, delta int64) {
625+
// add applies delta to the endpoint's counter, creating it if absent, and returns the exact
626+
// *atomic.Int64 instance that was mutated. Callers retain the returned pointer so the matching
627+
// decrement always lands on this same instance, even if the endpoint is later deleted (flap) and a
628+
// new counter is created under the same ID. See addedTokensEntry.
629+
func (t *concurrencyTracker) add(endpointID string, delta int64) *atomic.Int64 {
643630
t.mu.RLock()
644631
counter, exists := t.counts[endpointID]
645632
t.mu.RUnlock()
646633

647634
if exists {
648635
counter.Add(delta)
649-
return
636+
return counter
650637
}
651638

652639
t.mu.Lock()
653640
defer t.mu.Unlock()
654641

655642
if counter, exists = t.counts[endpointID]; exists {
656643
counter.Add(delta)
657-
return
644+
return counter
658645
}
659646

660647
counter = &atomic.Int64{}
661648
counter.Store(delta)
662649
t.counts[endpointID] = counter
650+
return counter
651+
}
652+
653+
// decrementClamped subtracts delta from counter with a hard floor at zero, following the canonical
654+
// CAS-floor pattern of predictedlatency.decrementEndpointCounter. It takes a bare *atomic.Int64,
655+
// not a sync.Map entry, because callers decrement the captured counter instance for their request,
656+
// which may be an orphaned counter after an endpoint flap and so must not be looked up in or
657+
// deleted from the live map.
658+
//
659+
// The floor is defense-in-depth: the captured-instance routing already keeps a release on its own
660+
// counter, and the floor additionally guarantees a release can never drive a counter negative. The
661+
// CAS loop keeps the floor race-safe against a concurrent inc on the same live instance: a plain
662+
// Add then Store(0) could clobber that inc.
663+
func decrementClamped(counter *atomic.Int64, delta int64) {
664+
for {
665+
current := counter.Load()
666+
if current <= 0 {
667+
return
668+
}
669+
next := current - delta
670+
if next < 0 {
671+
next = 0
672+
}
673+
if counter.CompareAndSwap(current, next) {
674+
return
675+
}
676+
}
663677
}
664678

665679
func (t *concurrencyTracker) delete(endpointID string) {

pkg/epp/framework/plugins/requestcontrol/dataproducer/inflightload/producer_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,168 @@ func TestInFlightLoadProducer_DumpStateCapsEndpoints(t *testing.T) {
312312
require.Equal(t, int64(104), state.Endpoints[0].Requests)
313313
}
314314

315+
// TestInFlightLoadProducer_FlapDoesNotUnderflow asserts that an in-flight request's release lands
316+
// on the exact counter instance it incremented, even after the endpoint is deleted and recreated
317+
// under the same NamespacedName. A release from a request that predates the flap hits the orphaned
318+
// counter, leaving the live counter accurate and never negative.
319+
func TestInFlightLoadProducer_FlapDoesNotUnderflow(t *testing.T) {
320+
t.Parallel()
321+
322+
producer := newTestProducer(t)
323+
ctx := context.Background()
324+
endpointName := "flap-endpoint"
325+
endpointID := fullEndpointName(endpointName)
326+
327+
// E joins the endpoint set. The same Endpoint object is reused for the
328+
// matching delete so the registeredEndpoints stale-delete guard allows
329+
// cleanup (mirrors the datalayer's same-pointer add/delete contract).
330+
epA := newStubSchedulingEndpoint(endpointName)
331+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
332+
Type: datalayer.EventAddOrUpdate,
333+
Endpoint: epA,
334+
}))
335+
336+
// Request A is routed to endpoint E.
337+
reqA := makeTokenRequest("req-A", 4)
338+
resA := makeSchedulingResult(endpointName)
339+
producer.PreRequest(ctx, reqA, resA)
340+
require.Equal(t, int64(1), producer.requestTracker.get(endpointID))
341+
342+
// E flaps: it leaves the endpoint set (NotReady) while A is still in flight,
343+
// dropping its counter.
344+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
345+
Type: datalayer.EventDelete,
346+
Endpoint: epA,
347+
}))
348+
require.Equal(t, int64(0), producer.requestTracker.get(endpointID), "counter dropped on delete")
349+
350+
// E rejoins with the same NamespacedName but a new Endpoint object; request
351+
// B's PreRequest recreates a fresh counter instance under the same key.
352+
epB := newStubSchedulingEndpoint(endpointName)
353+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
354+
Type: datalayer.EventAddOrUpdate,
355+
Endpoint: epB,
356+
}))
357+
reqB := makeTokenRequest("req-B", 4)
358+
resB := makeSchedulingResult(endpointName)
359+
producer.PreRequest(ctx, reqB, resB)
360+
require.Equal(t, int64(1), producer.requestTracker.get(endpointID), "B recreated the counter")
361+
362+
// A completes. Its release must hit the orphaned counter, not B's live one, which still holds B.
363+
reqA.SchedulingResult = resA
364+
producer.ResponseBody(ctx, reqA, &requestcontrol.Response{EndOfStream: true}, nil)
365+
require.Equal(t, int64(1), producer.requestTracker.get(endpointID),
366+
"A's release must not discount B's live counter")
367+
368+
// B completes. The live counter settles at 0 and never underflows.
369+
reqB.SchedulingResult = resB
370+
producer.ResponseBody(ctx, reqB, &requestcontrol.Response{EndOfStream: true}, nil)
371+
require.Equal(t, int64(0), producer.requestTracker.get(endpointID),
372+
"counter must settle at 0, never negative")
373+
}
374+
375+
// TestInFlightLoadProducer_CrashWithHighLoadDoesNotUnderflow models a pod that crashes while
376+
// holding many in-flight requests: its counter is dropped, the pod rejoins, and the crashed
377+
// requests drain late. Their releases hit the orphaned counter, so the live counter holds only
378+
// post-crash load and never goes negative.
379+
func TestInFlightLoadProducer_CrashWithHighLoadDoesNotUnderflow(t *testing.T) {
380+
t.Parallel()
381+
382+
producer := newTestProducer(t)
383+
ctx := context.Background()
384+
endpointName := "crash-endpoint"
385+
endpointID := fullEndpointName(endpointName)
386+
387+
const inFlight = 8
388+
389+
// The pod joins and accumulates several in-flight requests.
390+
epCrashed := newStubSchedulingEndpoint(endpointName)
391+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
392+
Type: datalayer.EventAddOrUpdate,
393+
Endpoint: epCrashed,
394+
}))
395+
reqs := make([]*fwksched.InferenceRequest, inFlight)
396+
results := make([]*fwksched.SchedulingResult, inFlight)
397+
for i := 0; i < inFlight; i++ {
398+
reqs[i] = makeTokenRequest(fmt.Sprintf("crash-req-%d", i), 4)
399+
results[i] = makeSchedulingResult(endpointName)
400+
producer.PreRequest(ctx, reqs[i], results[i])
401+
}
402+
require.Equal(t, int64(inFlight), producer.requestTracker.get(endpointID))
403+
404+
// The pod crashes: the endpoint is removed, dropping the counter that held
405+
// all in-flight requests.
406+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
407+
Type: datalayer.EventDelete,
408+
Endpoint: epCrashed,
409+
}))
410+
411+
// The pod restarts and rejoins; a fresh request lands on a new counter.
412+
epNew := newStubSchedulingEndpoint(endpointName)
413+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
414+
Type: datalayer.EventAddOrUpdate,
415+
Endpoint: epNew,
416+
}))
417+
reqNew := makeTokenRequest("post-crash-req", 4)
418+
resNew := makeSchedulingResult(endpointName)
419+
producer.PreRequest(ctx, reqNew, resNew)
420+
require.Equal(t, int64(1), producer.requestTracker.get(endpointID))
421+
422+
// The crashed requests drain late. Each release must hit the orphaned counter, so the live
423+
// counter holds only the post-crash request throughout.
424+
for i := 0; i < inFlight; i++ {
425+
reqs[i].SchedulingResult = results[i]
426+
producer.ResponseBody(ctx, reqs[i], &requestcontrol.Response{EndOfStream: true}, nil)
427+
require.Equal(t, int64(1), producer.requestTracker.get(endpointID),
428+
"crashed request's release must hit the orphan, not the live counter")
429+
}
430+
431+
// Only the post-crash request remains in flight.
432+
require.Equal(t, int64(1), producer.requestTracker.get(endpointID))
433+
434+
reqNew.SchedulingResult = resNew
435+
producer.ResponseBody(ctx, reqNew, &requestcontrol.Response{EndOfStream: true}, nil)
436+
require.Equal(t, int64(0), producer.requestTracker.get(endpointID))
437+
}
438+
439+
// TestInFlightLoadProducer_StaleDeleteIgnored verifies the registeredEndpoints
440+
// guard: a delete carrying a different Endpoint object than the one currently
441+
// registered (an out-of-order delete for an already-replaced pod) must NOT drop
442+
// the live counter, while the matching delete still cleans up.
443+
func TestInFlightLoadProducer_StaleDeleteIgnored(t *testing.T) {
444+
t.Parallel()
445+
446+
producer := newTestProducer(t)
447+
ctx := context.Background()
448+
endpointName := "stale-delete-endpoint"
449+
endpointID := fullEndpointName(endpointName)
450+
451+
// The current generation of the endpoint is registered and carries load.
452+
current := newStubSchedulingEndpoint(endpointName)
453+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
454+
Type: datalayer.EventAddOrUpdate,
455+
Endpoint: current,
456+
}))
457+
producer.requestTracker.add(endpointID, 3)
458+
459+
// A stale delete for a previous, already-replaced object (different pointer,
460+
// same NamespacedName) arrives out of order. It must be ignored.
461+
stale := newStubSchedulingEndpoint(endpointName)
462+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
463+
Type: datalayer.EventDelete,
464+
Endpoint: stale,
465+
}))
466+
require.Equal(t, int64(3), producer.requestTracker.get(endpointID),
467+
"stale delete for a replaced endpoint must not drop the live counter")
468+
469+
// The matching delete (the registered object) does clean up.
470+
require.NoError(t, producer.Extract(ctx, datalayer.EndpointEvent{
471+
Type: datalayer.EventDelete,
472+
Endpoint: current,
473+
}))
474+
require.Equal(t, int64(0), producer.requestTracker.get(endpointID))
475+
}
476+
315477
func TestInFlightLoadProducer_ConcurrencyStress(t *testing.T) {
316478
t.Parallel()
317479

0 commit comments

Comments
 (0)