Skip to content

Commit cfa8a91

Browse files
authored
fix(metrics): bound fairness_id label cardinality and prune GC'd flow series (#2119)
* fix(metrics): bound fairness_id label cardinality and prune GC'd flow series The fairness_id label on request and flow-control metrics is populated from a client request header (or agent-identity attribute), so its cardinality is not operator-bounded. Prometheus vectors never evict label combinations: every distinct fairness ID ever observed permanently allocated histogram series across both the deprecated and llm_d_epp metric families, growing pod RSS and scrape payloads without bound. Under flow-ID churn this was ~60% of remaining allocations in the full-path flow-control benchmark. Three changes, following the existing model-label precedent: - Bound fairness_id through a boundedLabel limiter (cap 1000, overflow to "other") in every record function that takes it, exactly as model names are already bounded via boundModels. - Bound model labels in RecordFlowControlRequestQueueDuration, which was missing the boundModels call its siblings have. - Prune a flow's flow-control series (both families) when the registry garbage-collects the flow, so the vectors track live flows. Pruning runs after cleanupFlowResources and outside the registry lock; DeletePartialMatch scans whole vectors and must not run under it. FullPath benchmark (registry/flow churn, M4 Pro, -benchtime=3s): 118k -> 213k d/s, 14.5KB -> 4.0KB per op, 264 -> 94 allocs per op. Signed-off-by: Luke Van Drie <lukevandrie@google.com> * test(metrics): cover fairness_id bounding paths; document label cardinality - Verify the request-metric family bounds fairness_id like the flow control family, and that RecordFlowControlRequestQueueDuration bounds its model labels. - Verify flow GC prunes the collected flow's metric series end to end. - Document the client-derived label caps and GC pruning in docs/metrics.md. Signed-off-by: Luke Van Drie <lukevandrie@google.com> * fix(metrics): guard overflow series from flow GC pruning A flow whose client-chosen fairness ID equals the overflow value must not delete the shared series that aggregates every capped-out tenant, so DeleteFlowControlFlowSeries now no-ops on the overflow value. Also document the revive-during-GC gauge race as an accepted limitation and clarify in docs/metrics.md that the model labels share one cap pool and that caps apply over the process lifetime. Signed-off-by: Luke Van Drie <lukevandrie@google.com> --------- Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 04cc371 commit cfa8a91

6 files changed

Lines changed: 260 additions & 0 deletions

File tree

docs/metrics.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ Names below omit the subsystem prefix. Unless a section states otherwise, the pr
6363
and the release stage is ALPHA. Request and latency metrics share the label set
6464
`{model_name, target_model_name, fairness_id, priority}`.
6565

66+
Client-derived label values are cardinality-bounded: `model_name` and `target_model_name` (from the
67+
request body) share a cap of 1000 distinct values, and `fairness_id` (from the
68+
`x-llm-d-inference-fairness-id` header) is capped at 1000 distinct values. Caps apply over the
69+
lifetime of the process; once a cap is reached, new values are reported as `other`. Model names
70+
configured through InferenceModelRewrite rules never fold to `other`. Flow control series for a
71+
`fairness_id` are removed when its flow is garbage collected.
72+
6673
### Request and latency
6774

6875
| Name | Type | Notes |

pkg/epp/flowcontrol/registry/registry.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"fmt"
2222
"slices"
23+
"strconv"
2324
"sync"
2425
"sync/atomic"
2526
"time"
@@ -31,6 +32,7 @@ import (
3132
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/contracts"
3233
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/framework/plugins/queue"
3334
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
35+
"github.com/llm-d/llm-d-router/pkg/epp/metrics"
3436
)
3537

3638
// propagateStatsDeltaFunc defines the callback function used to propagate statistics changes (deltas) up the hierarchy
@@ -472,6 +474,14 @@ func (fr *FlowRegistry) gcFlows() {
472474
}
473475

474476
fr.cleanupFlowResources(keysToClean)
477+
478+
// Prune the flows' metric series. Fairness IDs come from client input, so without pruning the
479+
// per-flow metric vectors grow monotonically with every fairness ID ever observed. Done after
480+
// cleanupFlowResources and outside fr.mu: DeletePartialMatch scans whole metric vectors, which
481+
// must not run under the registry write lock.
482+
for _, key := range keysToClean {
483+
metrics.DeleteFlowControlFlowSeries(key.ID, strconv.Itoa(key.Priority))
484+
}
475485
}
476486
}
477487

pkg/epp/flowcontrol/registry/registry_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package registry
1919
import (
2020
"context"
2121
"fmt"
22+
"strconv"
2223
"sync"
2324
"sync/atomic"
2425
"testing"
@@ -28,10 +29,12 @@ import (
2829
"github.com/stretchr/testify/assert"
2930
"github.com/stretchr/testify/require"
3031
testclock "k8s.io/utils/clock/testing"
32+
crmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
3133

3234
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/contracts"
3335
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
3436
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol/mocks"
37+
eppmetrics "github.com/llm-d/llm-d-router/pkg/epp/metrics"
3538
)
3639

3740
// --- Test Harness ---
@@ -1349,3 +1352,45 @@ func TestFlowRegistry_FlowErrorScoping(t *testing.T) {
13491352
assert.Equal(t, int32(concurrency), errorCount.Load(), "All requests should fail flow provisioning")
13501353
assert.Equal(t, int32(0), successCount.Load(), "No request should succeed if flow provisioning failed")
13511354
}
1355+
1356+
// countSeriesWithFairnessID gathers the global metrics registry and counts series carrying the
1357+
// given fairness_id label value, across all metric families.
1358+
func countSeriesWithFairnessID(t *testing.T, fairnessID string) int {
1359+
t.Helper()
1360+
families, err := crmetrics.Registry.Gather()
1361+
require.NoError(t, err, "gathering the metrics registry must succeed")
1362+
n := 0
1363+
for _, mf := range families {
1364+
for _, m := range mf.GetMetric() {
1365+
for _, lp := range m.GetLabel() {
1366+
if lp.GetName() == "fairness_id" && lp.GetValue() == fairnessID {
1367+
n++
1368+
}
1369+
}
1370+
}
1371+
}
1372+
return n
1373+
}
1374+
1375+
// Metric series are labeled by the flow's client-derived fairness ID, so they must not outlive the
1376+
// flow: gcFlows prunes them via metrics.DeleteFlowControlFlowSeries once the flow is collected.
1377+
func TestFlowRegistry_GarbageCollection_PrunesMetricSeries(t *testing.T) {
1378+
// Not parallel: reads the process-global metrics registry. The unique fairness ID keeps the
1379+
// assertions isolated from series recorded by other tests.
1380+
eppmetrics.Register()
1381+
h := newRegistryTestHarness(t, harnessOptions{manualGC: true})
1382+
const flowID = "gc-metric-prune-flow"
1383+
key := flowcontrol.FlowKey{ID: flowID, Priority: highPriority}
1384+
1385+
h.openConnectionOnFlow(key)
1386+
eppmetrics.RecordFlowControlRequestEnqueueDuration(
1387+
flowID, strconv.Itoa(highPriority), "Dispatched", time.Millisecond)
1388+
require.Positive(t, countSeriesWithFairnessID(t, flowID), "Setup: series must exist before GC")
1389+
1390+
h.fakeClock.Step(h.config.FlowGCTimeout + time.Second)
1391+
h.fr.ExecuteGCCycle()
1392+
1393+
h.assertFlowDoesNotExist(key, "Setup: idle flow must have been collected")
1394+
assert.Zero(t, countSeriesWithFairnessID(t, flowID),
1395+
"GC must prune every metric series labeled with the collected flow's fairness ID")
1396+
}

pkg/epp/metrics/cardinality.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,20 @@ func (b *boundedLabel) pin(v string) {
9797

9898
var modelLabelLimiter = newBoundedLabel(maxModelLabelValues)
9999

100+
// Fairness IDs are populated from a client request header (or an agent-identity attribute), so
101+
// like model names their cardinality is not operator-bounded. They label per-request and
102+
// flow-control metrics; without a cap, every distinct fairness ID ever observed permanently
103+
// grows the time series set. maxFairnessLabelValues bounds the distinct fairness_id label
104+
// values; values beyond the cap collapse to overflowValue.
105+
const maxFairnessLabelValues = 1000
106+
107+
var fairnessLabelLimiter = newBoundedLabel(maxFairnessLabelValues)
108+
109+
// boundFairnessID caps the request-derived fairness_id label.
110+
func boundFairnessID(fairnessID string) string {
111+
return fairnessLabelLimiter.bound(fairnessID)
112+
}
113+
100114
// PreAdmitModelLabels pins the given model names so they always emit their real
101115
// label value on model-labeled metrics, regardless of how many unconfigured
102116
// names have been admitted. The datastore calls this when InferenceModelRewrite

pkg/epp/metrics/cardinality_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package metrics
1919
import (
2020
"fmt"
2121
"testing"
22+
"time"
2223

2324
promtestutil "github.com/prometheus/client_golang/prometheus/testutil"
2425
"github.com/stretchr/testify/require"
@@ -98,3 +99,139 @@ func TestRecordRequestCounterBoundsModelCardinality(t *testing.T) {
9899
require.LessOrEqualf(t, count, testCap+1,
99100
"model_name cardinality must stay bounded by the cap, got %d series", count)
100101
}
102+
103+
// The fairness_id label is populated from a client request header, so the package-level limiter
104+
// must collapse an unbounded flood of distinct IDs into the overflow bucket instead of minting a
105+
// series per ID.
106+
func TestFairnessLabelFloodCollapsesToOverflow(t *testing.T) {
107+
const testCap = 5
108+
old := fairnessLabelLimiter
109+
fairnessLabelLimiter = newBoundedLabel(testCap)
110+
flowControlRequestEnqueueDuration.Reset()
111+
llmdFlowControlRequestEnqueueDuration.Reset()
112+
t.Cleanup(func() {
113+
fairnessLabelLimiter = old
114+
flowControlRequestEnqueueDuration.Reset()
115+
llmdFlowControlRequestEnqueueDuration.Reset()
116+
})
117+
118+
for i := 0; i < 1000; i++ {
119+
RecordFlowControlRequestEnqueueDuration(fmt.Sprintf("tenant-%d", i), "0", "Dispatched", time.Millisecond)
120+
}
121+
122+
// testCap admitted IDs + 1 overflow series, per family.
123+
require.Equal(t, testCap+1, promtestutil.CollectAndCount(flowControlRequestEnqueueDuration),
124+
"1000 distinct fairness IDs must collapse to cap+overflow series, not one series each")
125+
require.Equal(t, testCap+1, promtestutil.CollectAndCount(llmdFlowControlRequestEnqueueDuration),
126+
"the llm_d_epp family must be bounded identically")
127+
}
128+
129+
// DeleteFlowControlFlowSeries backs the flow registry's GC hook: once a flow is collected, its
130+
// series must not linger for the lifetime of the process.
131+
func TestDeleteFlowControlFlowSeries(t *testing.T) {
132+
old := fairnessLabelLimiter
133+
fairnessLabelLimiter = newBoundedLabel(10)
134+
flowControlRequestEnqueueDuration.Reset()
135+
llmdFlowControlRequestEnqueueDuration.Reset()
136+
t.Cleanup(func() {
137+
fairnessLabelLimiter = old
138+
flowControlRequestEnqueueDuration.Reset()
139+
llmdFlowControlRequestEnqueueDuration.Reset()
140+
})
141+
142+
RecordFlowControlRequestEnqueueDuration("tenant-a", "0", "Dispatched", time.Millisecond)
143+
RecordFlowControlRequestEnqueueDuration("tenant-a", "0", "Rejected", time.Millisecond)
144+
RecordFlowControlRequestEnqueueDuration("tenant-b", "0", "Dispatched", time.Millisecond)
145+
require.Equal(t, 3, promtestutil.CollectAndCount(flowControlRequestEnqueueDuration),
146+
"setup: expected one series per (fairness_id, outcome) pair")
147+
148+
DeleteFlowControlFlowSeries("tenant-a", "0")
149+
150+
require.Equal(t, 1, promtestutil.CollectAndCount(flowControlRequestEnqueueDuration),
151+
"all of tenant-a's series (every outcome) must be pruned; tenant-b's must survive")
152+
require.Equal(t, 1, promtestutil.CollectAndCount(llmdFlowControlRequestEnqueueDuration),
153+
"the llm_d_epp family must be pruned identically")
154+
}
155+
156+
// The bound is applied mechanically in every record function that takes a fairness ID; this guards
157+
// the request-metric family (distinct label ordering from the flow control family) against the
158+
// pattern regressing there.
159+
func TestFairnessLabelBoundOnRequestMetrics(t *testing.T) {
160+
const testCap = 3
161+
oldFairness := fairnessLabelLimiter
162+
fairnessLabelLimiter = newBoundedLabel(testCap)
163+
oldModels := modelLabelLimiter
164+
modelLabelLimiter = newBoundedLabel(10)
165+
requestCounter.Reset()
166+
llmdRequestCounter.Reset()
167+
t.Cleanup(func() {
168+
fairnessLabelLimiter = oldFairness
169+
modelLabelLimiter = oldModels
170+
requestCounter.Reset()
171+
llmdRequestCounter.Reset()
172+
})
173+
174+
for i := 0; i < 100; i++ {
175+
RecordRequestCounter("model-a", "model-a", fmt.Sprintf("tenant-%d", i), 0)
176+
}
177+
178+
require.Equal(t, testCap+1, promtestutil.CollectAndCount(llmdRequestCounter),
179+
"100 distinct fairness IDs must collapse to cap+overflow series on the request family")
180+
require.Equal(t, 1, promtestutil.CollectAndCount(requestCounter),
181+
"the deprecated family has no fairness_id label and must stay a single series")
182+
}
183+
184+
// RecordFlowControlRequestQueueDuration takes request-body model names; they must flow through the
185+
// model limiter like every sibling record function.
186+
func TestQueueDurationBoundsModelLabels(t *testing.T) {
187+
const testCap = 3
188+
oldModels := modelLabelLimiter
189+
modelLabelLimiter = newBoundedLabel(testCap)
190+
oldFairness := fairnessLabelLimiter
191+
fairnessLabelLimiter = newBoundedLabel(10)
192+
flowControlRequestQueueDuration.Reset()
193+
llmdFlowControlRequestQueueDuration.Reset()
194+
t.Cleanup(func() {
195+
modelLabelLimiter = oldModels
196+
fairnessLabelLimiter = oldFairness
197+
flowControlRequestQueueDuration.Reset()
198+
llmdFlowControlRequestQueueDuration.Reset()
199+
})
200+
201+
for i := 0; i < 100; i++ {
202+
m := fmt.Sprintf("model-%d", i)
203+
RecordFlowControlRequestQueueDuration("tenant", "0", "Dispatched", "pool", m, m, time.Millisecond)
204+
}
205+
206+
require.Equal(t, testCap+1, promtestutil.CollectAndCount(flowControlRequestQueueDuration),
207+
"100 distinct model names must collapse to cap+overflow series, not one series each")
208+
require.Equal(t, testCap+1, promtestutil.CollectAndCount(llmdFlowControlRequestQueueDuration),
209+
"the llm_d_epp family must be bounded identically")
210+
}
211+
212+
// A client can choose the overflow value itself as its fairness ID; GC of that flow must not
213+
// delete the shared overflow series that aggregates every capped-out tenant.
214+
func TestDeleteFlowControlFlowSeriesPreservesOverflowSeries(t *testing.T) {
215+
old := fairnessLabelLimiter
216+
fairnessLabelLimiter = newBoundedLabel(1)
217+
flowControlRequestEnqueueDuration.Reset()
218+
llmdFlowControlRequestEnqueueDuration.Reset()
219+
t.Cleanup(func() {
220+
fairnessLabelLimiter = old
221+
flowControlRequestEnqueueDuration.Reset()
222+
llmdFlowControlRequestEnqueueDuration.Reset()
223+
})
224+
225+
// tenant-a fills the single cap slot; tenant-b folds to the overflow series.
226+
RecordFlowControlRequestEnqueueDuration("tenant-a", "0", "Dispatched", time.Millisecond)
227+
RecordFlowControlRequestEnqueueDuration("tenant-b", "0", "Dispatched", time.Millisecond)
228+
require.Equal(t, 2, promtestutil.CollectAndCount(flowControlRequestEnqueueDuration),
229+
"setup: expected the admitted series plus the overflow series")
230+
231+
DeleteFlowControlFlowSeries(overflowValue, "0")
232+
233+
require.Equal(t, 2, promtestutil.CollectAndCount(flowControlRequestEnqueueDuration),
234+
"deleting the overflow value must be a no-op; the shared overflow series must survive")
235+
require.Equal(t, 2, promtestutil.CollectAndCount(llmdFlowControlRequestEnqueueDuration),
236+
"the llm_d_epp family must be preserved identically")
237+
}

0 commit comments

Comments
 (0)