Skip to content

Commit 9e94454

Browse files
committed
feat(flowcontrol): add per-band and global queue utilization gauges
Signed-off-by: Alok Behera <alokbeherak061@gmail.com>
1 parent f56f3bd commit 9e94454

5 files changed

Lines changed: 118 additions & 1 deletion

File tree

pkg/epp/flowcontrol/controller/internal/processor.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,36 @@ func (p *Processor) hasCapacity(priority int, itemByteSize uint64) (bool, contra
356356
return true, stats
357357
}
358358

359+
// recordQueueUtilization emits occupancy/limit ratio gauges for each priority band and, when a global limit is
360+
// configured, the global aggregate (priority ""). It reads a single Stats() snapshot; the data source is expected to
361+
// move with the engine-merge refactor, but the metric contract (names, labels, semantics) stays stable (#2102).
362+
// A dimension with no configured limit (capacity 0) is omitted rather than reported as a misleading 0.
363+
func (p *Processor) recordQueueUtilization() {
364+
stats := p.registry.Stats()
365+
366+
for priority, band := range stats.PerPriorityBandStats {
367+
priorityStr := strconv.Itoa(priority)
368+
if band.CapacityRequests > 0 {
369+
metrics.RecordFlowControlQueueUtilizationRequests(priorityStr, p.poolName,
370+
float64(band.Len)/float64(band.CapacityRequests))
371+
}
372+
if band.CapacityBytes > 0 {
373+
metrics.RecordFlowControlQueueUtilizationBytes(priorityStr, p.poolName,
374+
float64(band.ByteSize)/float64(band.CapacityBytes))
375+
}
376+
}
377+
378+
// Global aggregate across all bands, only when a global limit is configured.
379+
if stats.TotalCapacityRequests > 0 {
380+
metrics.RecordFlowControlQueueUtilizationRequests("", p.poolName,
381+
float64(stats.TotalLen)/float64(stats.TotalCapacityRequests))
382+
}
383+
if stats.TotalCapacityBytes > 0 {
384+
metrics.RecordFlowControlQueueUtilizationBytes("", p.poolName,
385+
float64(stats.TotalByteSize)/float64(stats.TotalCapacityBytes))
386+
}
387+
}
388+
359389
// dispatchCycle attempts to dispatch a single item by iterating through priority bands from highest to lowest.
360390
// It applies the configured policies for each band to select an item and then attempts to dispatch it.
361391
// It returns true if an item was successfully dispatched, and false otherwise.
@@ -380,6 +410,9 @@ func (p *Processor) dispatchCycle(ctx context.Context) bool {
380410
// Record pool saturation metric
381411
metrics.RecordFlowControlPoolSaturation(p.poolName, saturation)
382412

413+
// Record queue utilization ratios (the demand-side twin of saturation) from the same periodic sample.
414+
p.recordQueueUtilization()
415+
383416
priorities := p.registry.AllOrderedPriorityLevels()
384417
ceilings := p.usageLimitPolicy.ComputeLimit(ctx, saturation, priorities)
385418

pkg/epp/flowcontrol/integration_test.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package flowcontrol_test
1919
import (
2020
"context"
2121
"fmt"
22+
"strconv"
2223
"sync"
2324
"sync/atomic"
2425
"testing"
@@ -1163,7 +1164,9 @@ func TestFlowControlMetricsEmitted(t *testing.T) {
11631164
eppmetrics.Register()
11641165

11651166
detector := newBlockedDetector()
1166-
h := newHarness(t, harnessOpts{detector: detector})
1167+
// bandMaxRequests bounds the band so the request-count utilization ratio is computable
1168+
// (occupancy/limit); without a configured limit the ratio series is intentionally omitted.
1169+
h := newHarness(t, harnessOpts{detector: detector, bandMaxRequests: 10})
11671170

11681171
key := flowcontrol.FlowKey{ID: "metrics-flow", Priority: 0}
11691172

@@ -1186,6 +1189,17 @@ func TestFlowControlMetricsEmitted(t *testing.T) {
11861189
require.Greater(t, queueSizeGaugeSum(t, key.ID), 0.0,
11871190
"queue_size should be > 0 while a request is actively queued")
11881191

1192+
// The band is bounded (bandMaxRequests above), so its utilization ratio is occupancy/limit.
1193+
// Unlike queue_size, these gauges are refreshed by the dispatch cycle rather than synchronously
1194+
// on enqueue, so they trail admission by up to one tick.
1195+
priorityStr := strconv.Itoa(key.Priority)
1196+
require.Eventually(t, func() bool {
1197+
return queueUtilizationGauge(t, priorityStr) > 0
1198+
}, time.Second, time.Millisecond,
1199+
"utilization ratio should be > 0 while a request is actively queued")
1200+
require.LessOrEqual(t, queueUtilizationGauge(t, priorityStr), 1.0,
1201+
"utilization ratio must stay within [0,1]")
1202+
11891203
// Unblock the detector so the request finalizes deterministically via dispatch.
11901204
detector.Unblock(1)
11911205

@@ -1201,6 +1215,33 @@ func TestFlowControlMetricsEmitted(t *testing.T) {
12011215
// gauge is already back at 0 -- no polling needed.
12021216
require.Zero(t, queueSizeGaugeSum(t, key.ID),
12031217
"queue_size should return to 0 after the request finalizes")
1218+
1219+
// The utilization gauges are dispatch-cycle driven, so they trail the drain by up to one tick.
1220+
require.Eventually(t, func() bool {
1221+
return queueUtilizationGauge(t, priorityStr) == 0
1222+
}, time.Second, time.Millisecond,
1223+
"utilization ratio should return to 0 after the queue drains")
1224+
}
1225+
1226+
// queueUtilizationGauge returns the request-count utilization ratio recorded for the given priority
1227+
// band, or -1 if no series exists for that band.
1228+
func queueUtilizationGauge(t *testing.T, priority string) float64 {
1229+
t.Helper()
1230+
families, err := ctrlmetrics.Registry.Gather()
1231+
require.NoError(t, err)
1232+
for _, f := range families {
1233+
if f.GetName() != "llm_d_epp_flow_control_queue_utilization_requests" {
1234+
continue
1235+
}
1236+
for _, m := range f.GetMetric() {
1237+
for _, lp := range m.GetLabel() {
1238+
if lp.GetName() == "priority" && lp.GetValue() == priority {
1239+
return m.GetGauge().GetValue()
1240+
}
1241+
}
1242+
}
1243+
}
1244+
return -1
12041245
}
12051246

12061247
// ============================================================================

pkg/epp/metrics/llm_d_router_metrics.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,24 @@ var (
380380
[]string{"inference_pool"},
381381
)
382382

383+
llmdFlowControlQueueUtilizationRequests = prometheus.NewGaugeVec(
384+
prometheus.GaugeOpts{
385+
Subsystem: LLMDRouterEndpointPickerSubsystem,
386+
Name: "flow_control_queue_utilization_requests",
387+
Help: metricsutil.HelpMsgWithStability("Fraction of the request-count limit currently occupied by the Flow Control queue (0.0-1.0), per priority band. The global aggregate is reported with priority=\"\" and only when a global request limit is configured; a dimension with no limit is omitted rather than reported as 0.", compbasemetrics.ALPHA),
388+
},
389+
[]string{"priority", "inference_pool"},
390+
)
391+
392+
llmdFlowControlQueueUtilizationBytes = prometheus.NewGaugeVec(
393+
prometheus.GaugeOpts{
394+
Subsystem: LLMDRouterEndpointPickerSubsystem,
395+
Name: "flow_control_queue_utilization_bytes",
396+
Help: metricsutil.HelpMsgWithStability("Fraction of the byte-size limit currently occupied by the Flow Control queue (0.0-1.0), per priority band. The global aggregate is reported with priority=\"\" and only when a global byte limit is configured; a dimension with no limit is omitted rather than reported as 0.", compbasemetrics.ALPHA),
397+
},
398+
[]string{"priority", "inference_pool"},
399+
)
400+
383401
llmdFlowControlRequestsTotal = prometheus.NewCounterVec(
384402
prometheus.CounterOpts{
385403
Subsystem: LLMDRouterEndpointPickerSubsystem,

pkg/epp/metrics/metrics.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,8 @@ func Register(customCollectors ...prometheus.Collector) {
472472
metrics.Registry.MustRegister(llmdFlowControlQueueBytes)
473473
metrics.Registry.MustRegister(flowControlPoolSaturation)
474474
metrics.Registry.MustRegister(llmdFlowControlPoolSaturation)
475+
metrics.Registry.MustRegister(llmdFlowControlQueueUtilizationRequests)
476+
metrics.Registry.MustRegister(llmdFlowControlQueueUtilizationBytes)
475477
metrics.Registry.MustRegister(flowControlRequestEnqueueDuration)
476478
metrics.Registry.MustRegister(llmdFlowControlRequestEnqueueDuration)
477479
metrics.Registry.MustRegister(llmdFlowControlRequestsTotal)
@@ -542,6 +544,8 @@ func Reset() {
542544
llmdFlowControlQueueBytes.Reset()
543545
flowControlPoolSaturation.Reset()
544546
llmdFlowControlPoolSaturation.Reset()
547+
llmdFlowControlQueueUtilizationRequests.Reset()
548+
llmdFlowControlQueueUtilizationBytes.Reset()
545549
flowControlRequestEnqueueDuration.Reset()
546550
llmdFlowControlRequestEnqueueDuration.Reset()
547551
flowControlDispatchCycleDuration.Reset()
@@ -906,6 +910,20 @@ func RecordFlowControlPoolSaturation(inferencePool string, saturation float64) {
906910
llmdFlowControlPoolSaturation.WithLabelValues(inferencePool).Set(saturation)
907911
}
908912

913+
// RecordFlowControlQueueUtilizationRequests sets the request-count queue utilization ratio (occupancy/limit, 0.0-1.0)
914+
// for a priority band, or the global aggregate when priority is "". Callers only invoke this for a bounded dimension,
915+
// so an unlimited limit yields no series rather than a misleading 0.
916+
func RecordFlowControlQueueUtilizationRequests(priority, inferencePool string, ratio float64) {
917+
llmdFlowControlQueueUtilizationRequests.WithLabelValues(priority, inferencePool).Set(ratio)
918+
}
919+
920+
// RecordFlowControlQueueUtilizationBytes sets the byte-size queue utilization ratio (occupancy/limit, 0.0-1.0) for a
921+
// priority band, or the global aggregate when priority is "". Callers only invoke this for a bounded dimension, so an
922+
// unlimited limit yields no series rather than a misleading 0.
923+
func RecordFlowControlQueueUtilizationBytes(priority, inferencePool string, ratio float64) {
924+
llmdFlowControlQueueUtilizationBytes.WithLabelValues(priority, inferencePool).Set(ratio)
925+
}
926+
909927
// IncFlowControlRequestsTotal increments the total request counter for a given outcome.
910928
func IncFlowControlRequestsTotal(outcome, priority, inferencePool string) {
911929
llmdFlowControlRequestsTotal.WithLabelValues(outcome, priority, inferencePool).Inc()

release-notes.d/unreleased/2190.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
pr: 2190
3+
url: https://github.com/llm-d/llm-d-router/pull/2190
4+
author: sudoalok
5+
date: 2026-07-25
6+
---
7+
Added flow control queue utilization gauges: `llm_d_epp_flow_control_queue_utilization_requests` and `llm_d_epp_flow_control_queue_utilization_bytes`, reporting queue occupancy as a fraction of the configured limit (0.0-1.0) per priority band, plus a global aggregate series (priority="") when a global limit is configured. Operators can alert on "queue at N% of its limit" directly, without joining config values into PromQL.

0 commit comments

Comments
 (0)