Skip to content

Commit c769ae9

Browse files
committed
fix(flowcontrol): surface stale-metrics saturation and fix pool_saturation help text
The utilization detector scores endpoints with missing or stale metrics as fully saturated, and both detectors score an empty pool as 1.0. Dispatch halts at saturation 1.0, so a fleet-wide scrape failure stops all dispatch and nothing points at metrics collection as the cause. The pool_saturation help text also claimed a 0.0-1.0 range, but both detectors return values above 1 when the pool is oversubscribed. - Add flow_control_stale_endpoints{detector}: candidate endpoints scored as saturated because their metrics are missing or older than the staleness threshold. Emitted under the llm_d_epp prefix only. - Log the stale condition from the utilization detector at default verbosity, at most once per 30s. Saturation runs every dispatch cycle (~1ms), so the log is bounded by time, not call count. - Fix the pool_saturation help text on both gauges: 1.0 is the gating set point, values above 1.0 measure oversubscription, and empty-pool and stale-metrics conditions read as 1.0 (fail-closed). Values stay unclamped. - Keep fail-closed for stale metrics on a non-empty pool and document the decision in the detector README: admitting blind on missing data would overload model servers with no backpressure signal at all. Fixes #2100 Part of #1187 Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 34478d1 commit c769ae9

6 files changed

Lines changed: 185 additions & 13 deletions

File tree

docs/metrics.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,25 @@ Exposed when the `flowControl` feature gate is enabled.
242242

243243
* **Type:** Gauge
244244
* **Labels:** `inference_pool`
245-
* **Description:** Current saturation level of the inference pool (0.0 = empty, 1.0 = fully
246-
saturated).
245+
* **Description:** Pool saturation signal gating dispatch. 1.0 is the gating set point; values
246+
above 1.0 indicate the magnitude of oversubscription past it (deliberately not clamped). An
247+
empty pool reads as 1.0, and with the default utilization detector, endpoints with missing or
248+
stale metrics score as fully saturated (fail-closed).
247249
* **Usage:** When saturation reaches the usage limit threshold, the dispatch cycle skips
248-
dispatching and requests remain queued. Sustained 1.0 indicates all backends are at capacity.
250+
dispatching and requests remain queued. A reading pinned at exactly 1.0 can be fail-closed
251+
stale-metrics or an empty pool rather than genuine overload (which typically reads above 1.0);
252+
check `flow_control_stale_endpoints` to disambiguate.
253+
254+
#### `flow_control_stale_endpoints`
255+
256+
* **Type:** Gauge
257+
* **Labels:** `detector`
258+
* **Description:** Number of candidate endpoints whose metrics are missing or older than the
259+
staleness threshold, as of the most recent saturation evaluation. Recorded by the utilization
260+
saturation detector; emitted under the `llm_d_epp` prefix only (no deprecated
261+
`inference_extension_*` twin).
262+
* **Usage:** A nonzero value during a dispatch stall indicates a model-server metrics collection
263+
problem (scrape path, port, TLS, auth) rather than genuine overload.
249264

250265
#### `flow_control_requests_total`
251266

pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ The global pool saturation is then evaluated across all candidate endpoints as a
2323
PoolSaturation = Average(EndpointScore)
2424

2525
**Heterogeneous Deployments:** Because this detector calculates saturation as an unweighted average of individual endpoint scores, it treats all endpoints equally regardless of their physical capacity. In deployments with heterogeneous compute (e.g., mixing H100 and L4 nodes), a small, saturated endpoint has the exact same impact on global backpressure as a massive, saturated endpoint. Contrast this with the Concurrency Detector, which evaluates saturation as a single aggregate fraction, biasing toward larger endpoints.
26-
*Note: Endpoints with missing or stale metrics are aggressively scored as 100% saturated.*
26+
*Note: Endpoints with missing or stale metrics are aggressively scored as 100% saturated (fail-closed).*
27+
28+
**Operational dependency:** because stale endpoints score as saturated, Flow Control dispatch depends on the
29+
health of model-server metrics collection (scrape path, port, TLS, auth). A fleet-wide scrape outage pins
30+
`flow_control_pool_saturation` at 1.0 and halts dispatch entirely. The `flow_control_stale_endpoints` gauge and a
31+
rate-limited detector log distinguish this from genuine overload: stale endpoints read exactly 1.0, while genuine
32+
oversubscription typically reads above 1.0. This fail-closed posture is deliberate — admitting blind on missing
33+
data risks overloading model servers with no backpressure signal at all.
2734

2835
### Role in Scheduling (The Traffic Shaper)
2936
The detector implements the `Filter` interface to protect individual endpoints. It removes endpoints from candidate lists if their telemetry is stale, or if they exceed specific safety limits:

pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization/detector.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"context"
2525
"encoding/json"
2626
"fmt"
27+
"sync/atomic"
2728
"time"
2829

2930
"github.com/go-logr/logr"
@@ -34,11 +35,17 @@ import (
3435
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
3536
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
3637
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
38+
"github.com/llm-d/llm-d-router/pkg/epp/metrics"
3739
)
3840

3941
const (
4042
// UtilizationDetectorType is the unique identifier for this plugin.
4143
UtilizationDetectorType = "utilization-detector"
44+
45+
// staleWarnInterval bounds how often the detector logs about endpoints with missing or stale
46+
// metrics. Saturation is evaluated every dispatch cycle (~1ms), so the condition must be logged
47+
// on a time budget, never per evaluation.
48+
staleWarnInterval = 30 * time.Second
4249
)
4350

4451
// UtilizationDetectorFactory instantiates the detector plugin using the provided JSON parameters.
@@ -69,6 +76,11 @@ var (
6976
type Detector struct {
7077
config Config
7178
typedName fwkplugin.TypedName
79+
logger logr.Logger
80+
81+
// lastStaleWarnNanos is the wall-clock time (UnixNano) of the most recent stale-metrics log,
82+
// accessed atomically because Saturation may be called concurrently.
83+
lastStaleWarnNanos atomic.Int64
7284
}
7385

7486
// NewDetector creates a new instance of the Utilization Detector.
@@ -95,6 +107,7 @@ func NewDetector(name string, cfg Config, logger logr.Logger) *Detector {
95107
return &Detector{
96108
config: cfg,
97109
typedName: typedName,
110+
logger: pluginLogger,
98111
}
99112
}
100113

@@ -114,28 +127,61 @@ func (d *Detector) TypedName() fwkplugin.TypedName {
114127
// PodScore = Max(WaitingQueue / QueueThreshold, KVCacheUsage / KVCacheThreshold)
115128
func (d *Detector) Saturation(_ context.Context, candidates []datalayer.Endpoint) float64 {
116129
if len(candidates) == 0 {
130+
// No candidates means no stale endpoints. Keeping the gauge current here prevents a stale
131+
// reading from a previous evaluation misattributing an empty-pool stall to a metrics
132+
// collection failure.
133+
metrics.RecordFlowControlStaleEndpoints(d.typedName.Name, 0)
117134
return 1.0
118135
}
119136

120137
var totalScore float64
138+
staleCount := 0
121139
for _, e := range candidates {
122-
metrics := e.GetMetrics()
140+
podMetrics := e.GetMetrics()
123141

124-
if metrics == nil || time.Since(metrics.UpdateTime) > d.config.MetricsStalenessThreshold {
142+
if podMetrics == nil || time.Since(podMetrics.UpdateTime) > d.config.MetricsStalenessThreshold {
143+
// Fail closed: an endpoint whose metrics are missing or stale scores as fully saturated. A
144+
// fleet-wide metrics collection failure therefore halts dispatch entirely rather than
145+
// admitting blind; the gauge and the rate-limited log below exist so operators can tell that
146+
// stall apart from genuine overload (which typically scores above 1.0).
125147
totalScore += 1.0
148+
staleCount++
126149
continue
127150
}
128151

129-
qRatio := float64(metrics.WaitingQueueSize) / float64(d.config.QueueDepthThreshold)
130-
kvRatio := metrics.KVCacheUsagePercent / d.config.KVCacheUtilThreshold
152+
qRatio := float64(podMetrics.WaitingQueueSize) / float64(d.config.QueueDepthThreshold)
153+
kvRatio := podMetrics.KVCacheUsagePercent / d.config.KVCacheUtilThreshold
131154

132155
// Roofline Analysis: The pod is saturated if either resource is exhausted.
133156
totalScore += max(qRatio, kvRatio)
134157
}
135158

159+
metrics.RecordFlowControlStaleEndpoints(d.typedName.Name, staleCount)
160+
if staleCount > 0 {
161+
d.maybeLogStaleEndpoints(staleCount, len(candidates))
162+
}
163+
136164
return totalScore / float64(len(candidates))
137165
}
138166

167+
// maybeLogStaleEndpoints logs the stale-metrics condition at most once per staleWarnInterval.
168+
func (d *Detector) maybeLogStaleEndpoints(staleCount, total int) {
169+
now := time.Now().UnixNano()
170+
last := d.lastStaleWarnNanos.Load()
171+
if now-last < int64(staleWarnInterval) {
172+
return
173+
}
174+
if !d.lastStaleWarnNanos.CompareAndSwap(last, now) {
175+
return // Another goroutine logged concurrently.
176+
}
177+
d.logger.V(logutil.DEFAULT).Info(
178+
"Endpoints with missing or stale metrics are scored as fully saturated (fail-closed); "+
179+
"if dispatch is stalled, check model-server metrics collection (scrape path, port, TLS, auth)",
180+
"staleEndpoints", staleCount,
181+
"totalEndpoints", total,
182+
"metricsStalenessThreshold", d.config.MetricsStalenessThreshold.String())
183+
}
184+
139185
// Filter blocks traffic to specific pods that are physically saturated or exceeding their safety limits.
140186
//
141187
// It applies a relaxed limit (Threshold * (1 + Headroom)) to allow for scheduling flexibility and burst tolerance.
@@ -151,12 +197,12 @@ func (d *Detector) Filter(
151197
filtered := make([]fwksched.Endpoint, 0, len(endpoints))
152198

153199
for _, endpoint := range endpoints {
154-
metrics := endpoint.GetMetrics()
155-
if metrics == nil || time.Since(metrics.UpdateTime) > d.config.MetricsStalenessThreshold {
200+
podMetrics := endpoint.GetMetrics()
201+
if podMetrics == nil || time.Since(podMetrics.UpdateTime) > d.config.MetricsStalenessThreshold {
156202
continue
157203
}
158204

159-
if float64(metrics.WaitingQueueSize) < qLimit && metrics.KVCacheUsagePercent < kvLimit {
205+
if float64(podMetrics.WaitingQueueSize) < qLimit && podMetrics.KVCacheUsagePercent < kvLimit {
160206
filtered = append(filtered, endpoint)
161207
}
162208
}

pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization/detector_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ import (
2424
"github.com/go-logr/logr"
2525
"github.com/stretchr/testify/require"
2626
"k8s.io/apimachinery/pkg/types"
27+
ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
2728

2829
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
2930
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
3031
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
32+
eppmetrics "github.com/llm-d/llm-d-router/pkg/epp/metrics"
3133
)
3234

3335
func makePodMetric(name string, queueDepth int, kvUsage float64, updateTime time.Time) fwkdl.Endpoint {
@@ -370,3 +372,70 @@ func TestDetector_Filter(t *testing.T) {
370372
})
371373
}
372374
}
375+
376+
// TestDetector_StaleEndpointObservability verifies that Saturation records the stale-endpoint
377+
// gauge (keyed by detector name) and that the stale-metrics log is time-bounded.
378+
func TestDetector_StaleEndpointObservability(t *testing.T) {
379+
t.Parallel()
380+
381+
eppmetrics.Register()
382+
383+
// A wide staleness threshold keeps fresh pods deterministically fresh on slow CI machines;
384+
// the stale pod is stamped far past the threshold.
385+
config := Config{
386+
QueueDepthThreshold: 5,
387+
KVCacheUtilThreshold: 0.90,
388+
MetricsStalenessThreshold: time.Hour,
389+
}
390+
// A unique detector name isolates this test's gauge series from parallel tests.
391+
detectorName := "stale-observability-test"
392+
detector := NewDetector(detectorName, config, logr.Discard())
393+
394+
staleGaugeValue := func() float64 {
395+
families, err := ctrlmetrics.Registry.Gather()
396+
require.NoError(t, err)
397+
for _, f := range families {
398+
if f.GetName() != "llm_d_epp_flow_control_stale_endpoints" {
399+
continue
400+
}
401+
for _, m := range f.GetMetric() {
402+
for _, l := range m.GetLabel() {
403+
if l.GetName() == "detector" && l.GetValue() == detectorName {
404+
return m.GetGauge().GetValue()
405+
}
406+
}
407+
}
408+
}
409+
return -1 // Series absent.
410+
}
411+
412+
baseTime := time.Now()
413+
pods := []fwkdl.Endpoint{
414+
makePodMetric("fresh", 1, 0.1, baseTime),
415+
makePodMetric("stale", 1, 0.1, baseTime.Add(-2*time.Hour)),
416+
fwkdl.NewEndpoint(&fwkdl.EndpointMetadata{
417+
NamespacedName: types.NamespacedName{Name: "nil-metrics", Namespace: "ns1"},
418+
}, nil),
419+
}
420+
421+
detector.Saturation(context.Background(), pods)
422+
require.Equal(t, 2.0, staleGaugeValue(), "stale and nil-metrics endpoints should both be counted")
423+
firstWarn := detector.lastStaleWarnNanos.Load()
424+
require.NotZero(t, firstWarn, "first stale observation should record a log timestamp")
425+
426+
// A second observation within staleWarnInterval must not log again.
427+
detector.Saturation(context.Background(), pods)
428+
require.Equal(t, firstWarn, detector.lastStaleWarnNanos.Load(),
429+
"stale-metrics logging must be time-bounded, not per evaluation")
430+
431+
// An empty candidate list has no stale endpoints; the gauge must not stay pinned at its last
432+
// value, or an empty-pool stall reads as a metrics collection failure.
433+
detector.Saturation(context.Background(), []fwkdl.Endpoint{})
434+
require.Equal(t, 0.0, staleGaugeValue(), "gauge should read zero for an empty candidate list")
435+
436+
// Re-observe staleness, then confirm fresh metrics clear it.
437+
detector.Saturation(context.Background(), pods)
438+
require.Equal(t, 2.0, staleGaugeValue(), "staleness should be re-observed after the empty list")
439+
detector.Saturation(context.Background(), []fwkdl.Endpoint{makePodMetric("fresh", 1, 0.1, time.Now())})
440+
require.Equal(t, 0.0, staleGaugeValue(), "gauge should return to zero when staleness clears")
441+
}

pkg/epp/metrics/llm_d_router_metrics.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,11 +351,30 @@ var (
351351
prometheus.GaugeOpts{
352352
Subsystem: LLMDRouterEndpointPickerSubsystem,
353353
Name: "flow_control_pool_saturation",
354-
Help: metricsutil.HelpMsgWithStability("Current saturation level of the inference pool (0.0 = empty, 1.0 = fully saturated).", compbasemetrics.ALPHA),
354+
Help: metricsutil.HelpMsgWithStability(
355+
"Pool saturation signal gating Flow Control dispatch. 1.0 is the gating set point; values above 1.0 "+
356+
"indicate the magnitude of oversubscription past it. An empty pool reads as 1.0. With the default "+
357+
"utilization detector, endpoints with missing or stale metrics score as fully saturated "+
358+
"(fail-closed; see flow_control_stale_endpoints).",
359+
compbasemetrics.ALPHA),
355360
},
356361
[]string{"inference_pool"},
357362
)
358363

364+
llmdFlowControlStaleEndpoints = prometheus.NewGaugeVec(
365+
prometheus.GaugeOpts{
366+
Subsystem: LLMDRouterEndpointPickerSubsystem,
367+
Name: "flow_control_stale_endpoints",
368+
Help: metricsutil.HelpMsgWithStability(
369+
"Number of candidate endpoints whose metrics are missing or older than the staleness threshold, as of "+
370+
"the most recent saturation evaluation. Recorded by the utilization saturation detector, which scores "+
371+
"these endpoints as fully saturated in flow_control_pool_saturation (fail-closed): a nonzero value "+
372+
"during a dispatch stall indicates a metrics collection problem rather than genuine overload.",
373+
compbasemetrics.ALPHA),
374+
},
375+
[]string{"detector"},
376+
)
377+
359378
llmdFlowControlRequestsTotal = prometheus.NewCounterVec(
360379
prometheus.CounterOpts{
361380
Subsystem: LLMDRouterEndpointPickerSubsystem,

pkg/epp/metrics/metrics.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,13 @@ var (
368368
prometheus.GaugeOpts{
369369
Subsystem: inferenceExtension,
370370
Name: "flow_control_pool_saturation",
371-
Help: metricsutil.HelpMsgWithStability("[Deprecated: Use llm_d_epp_flow_control_pool_saturation] Current saturation level of the inference pool (0.0 = empty, 1.0 = fully saturated).", compbasemetrics.ALPHA),
371+
Help: metricsutil.HelpMsgWithStability(
372+
"[Deprecated: Use llm_d_epp_flow_control_pool_saturation] Pool saturation signal gating Flow Control "+
373+
"dispatch. 1.0 is the gating set point; values above 1.0 indicate the magnitude of oversubscription "+
374+
"past it. An empty pool reads as 1.0. With the default utilization detector, endpoints with missing "+
375+
"or stale metrics score as fully saturated (fail-closed; see "+
376+
"llm_d_epp_flow_control_stale_endpoints).",
377+
compbasemetrics.ALPHA),
372378
},
373379
[]string{"inference_pool"},
374380
)
@@ -470,6 +476,9 @@ func Register(customCollectors ...prometheus.Collector) {
470476
metrics.Registry.MustRegister(llmdFlowControlQueueBytes)
471477
metrics.Registry.MustRegister(flowControlPoolSaturation)
472478
metrics.Registry.MustRegister(llmdFlowControlPoolSaturation)
479+
// No deprecated inference_extension twin: new flow control metrics are emitted under the
480+
// llm_d_epp prefix only.
481+
metrics.Registry.MustRegister(llmdFlowControlStaleEndpoints)
473482
metrics.Registry.MustRegister(flowControlRequestEnqueueDuration)
474483
metrics.Registry.MustRegister(llmdFlowControlRequestEnqueueDuration)
475484
metrics.Registry.MustRegister(llmdFlowControlRequestsTotal)
@@ -538,6 +547,7 @@ func Reset() {
538547
llmdFlowControlQueueBytes.Reset()
539548
flowControlPoolSaturation.Reset()
540549
llmdFlowControlPoolSaturation.Reset()
550+
llmdFlowControlStaleEndpoints.Reset()
541551
flowControlRequestEnqueueDuration.Reset()
542552
llmdFlowControlRequestEnqueueDuration.Reset()
543553
flowControlDispatchCycleDuration.Reset()
@@ -890,6 +900,12 @@ func RecordFlowControlPoolSaturation(inferencePool string, saturation float64) {
890900
llmdFlowControlPoolSaturation.WithLabelValues(inferencePool).Set(saturation)
891901
}
892902

903+
// RecordFlowControlStaleEndpoints records how many candidate endpoints the given saturation
904+
// detector scored as fully saturated because their metrics were missing or stale.
905+
func RecordFlowControlStaleEndpoints(detector string, count int) {
906+
llmdFlowControlStaleEndpoints.WithLabelValues(detector).Set(float64(count))
907+
}
908+
893909
// IncFlowControlRequestsTotal increments the total request counter for a given outcome.
894910
func IncFlowControlRequestsTotal(outcome, priority, inferencePool string) {
895911
llmdFlowControlRequestsTotal.WithLabelValues(outcome, priority, inferencePool).Inc()

0 commit comments

Comments
 (0)