Skip to content

Commit 2e76514

Browse files
authored
test(flowcontrol): cover feature-gate wiring and the admission-to-controller seam (#2129)
Two graduation-blocker gaps in flow control test coverage: - No test exercised the flowControl feature gate through the config loader to admission-controller selection in either direction. Add TestFlowControlFeatureGateAdmissionControlWiring, which drives parseConfigurationPhaseOne -> parseConfigurationPhaseTwo -> initAdmissionControl and asserts: gate on wires *FlowControlAdmissionController with a non-nil FlowControlConfig and a control plane; flowControl=false restores *LegacyAdmissionController with neither. The no-stanza case reads the gate's registered default programmatically, so the test flips cleanly with the enable-by-default PR. - The flowcontrol integration suite enters at EnqueueAndWait and the requestcontrol admission tests mock the flow controller, so nothing asserted status + dropped-reason header against the real stack. Add TestFlowControlAdmissionController_RealControllerSeam, wiring NewFlowControlAdmissionController to a real FlowController and FlowRegistry and asserting the errcommon.Error code and x-llm-d-request-dropped-reason header for dispatched, rejected-capacity, no-endpoints, TTL-eviction, context-cancel, and shutdown outcomes, each forced deterministically. Fixes #2103 Part of #1187 Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 2121d7a commit 2e76514

2 files changed

Lines changed: 382 additions & 0 deletions

File tree

cmd/epp/runner/feature_gate_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@ package runner
1818

1919
import (
2020
"context"
21+
"os"
2122
"testing"
2223

2324
"github.com/stretchr/testify/require"
2425

26+
"github.com/llm-d/llm-d-router/pkg/epp/datastore"
27+
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol"
28+
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/contracts"
29+
"github.com/llm-d/llm-d-router/pkg/epp/requestcontrol"
2530
runserver "github.com/llm-d/llm-d-router/pkg/epp/server"
2631
)
2732

@@ -50,3 +55,102 @@ featureGates:
5055
require.False(t, r.featureGates[runserver.HAPopulateNonLeaderDatastoreFeatureGate])
5156
})
5257
}
58+
59+
// TestFlowControlFeatureGateAdmissionControlWiring exercises the flowControl feature gate through
60+
// the production config path (parseConfigurationPhaseOne -> parseConfigurationPhaseTwo ->
61+
// initAdmissionControl) in both directions:
62+
// - gate on: the FlowControlAdmissionController is wired, the loader emits a non-nil
63+
// FlowControlConfig, and the flow registry is exposed as the priority band control plane;
64+
// - gate off: the LegacyAdmissionController is wired and no flow control config is built.
65+
//
66+
// The "no featureGates stanza" case reads the gate's registered default from the parsed
67+
// feature-gate map instead of hardcoding it, so this test keeps passing unchanged when the gate
68+
// flips to enabled-by-default (#2104) and pins that the flip actually changes the default wiring.
69+
func TestFlowControlFeatureGateAdmissionControlWiring(t *testing.T) {
70+
// The deprecated ENABLE_EXPERIMENTAL_FLOW_CONTROL_LAYER env var appends the gate to the config
71+
// during phase two; clear it so only the featureGates stanza under test drives the outcome.
72+
if v, ok := os.LookupEnv(enableExperimentalFlowControlLayer); ok {
73+
require.NoError(t, os.Unsetenv(enableExperimentalFlowControlLayer))
74+
t.Cleanup(func() { _ = os.Setenv(enableExperimentalFlowControlLayer, v) })
75+
}
76+
77+
boolPtr := func(b bool) *bool { return &b }
78+
testCases := []struct {
79+
name string
80+
configText string
81+
// wantEnabled nil means "expect whatever default the runner registered for the gate",
82+
// read programmatically from the feature gates parsed out of the stanza-less config.
83+
wantEnabled *bool
84+
}{
85+
{
86+
name: "no featureGates stanza follows the registered default",
87+
configText: `apiVersion: llm-d.ai/v1alpha1
88+
kind: EndpointPickerConfig
89+
`,
90+
wantEnabled: nil,
91+
},
92+
{
93+
name: "flowControl gate enabled wires the flow control admission controller",
94+
configText: `apiVersion: llm-d.ai/v1alpha1
95+
kind: EndpointPickerConfig
96+
featureGates:
97+
- flowControl
98+
`,
99+
wantEnabled: boolPtr(true),
100+
},
101+
{
102+
name: "flowControl=false restores the legacy admission controller",
103+
configText: `apiVersion: llm-d.ai/v1alpha1
104+
kind: EndpointPickerConfig
105+
featureGates:
106+
- flowControl=false
107+
`,
108+
wantEnabled: boolPtr(false),
109+
},
110+
}
111+
112+
for _, tc := range testCases {
113+
t.Run(tc.name, func(t *testing.T) {
114+
ctx, cancel := context.WithCancel(context.Background())
115+
defer cancel()
116+
117+
opts := runserver.NewOptions()
118+
opts.ConfigText = tc.configText
119+
opts.PoolName = "test-pool"
120+
121+
r := NewRunner()
122+
rawConfig, err := r.parseConfigurationPhaseOne(ctx, opts)
123+
require.NoError(t, err)
124+
125+
wantEnabled := r.featureGates[flowcontrol.FeatureGate] // Registered default.
126+
if tc.wantEnabled != nil {
127+
wantEnabled = *tc.wantEnabled
128+
require.Equal(t, wantEnabled, r.featureGates[flowcontrol.FeatureGate],
129+
"the loader should honor the explicit featureGates stanza")
130+
}
131+
132+
ds := datastore.NewDatastore(ctx, r.setupMetricsCollection(opts))
133+
eppConfig, err := r.parseConfigurationPhaseTwo(ctx, rawConfig, ds)
134+
require.NoError(t, err)
135+
136+
endpointCandidates := contracts.EndpointCandidates(
137+
requestcontrol.NewDatastoreEndpointCandidates(ds))
138+
_, admissionController, controlPlane :=
139+
r.initAdmissionControl(ctx, opts, eppConfig, endpointCandidates)
140+
141+
if wantEnabled {
142+
require.IsType(t, &requestcontrol.FlowControlAdmissionController{}, admissionController)
143+
require.NotNil(t, eppConfig.FlowControlConfig,
144+
"the loader should build a flow control config when the gate is on")
145+
require.NotNil(t, controlPlane,
146+
"the flow registry should be exposed as the priority band control plane")
147+
} else {
148+
require.IsType(t, &requestcontrol.LegacyAdmissionController{}, admissionController)
149+
require.Nil(t, eppConfig.FlowControlConfig,
150+
"the loader should not build a flow control config when the gate is off")
151+
require.Nil(t, controlPlane,
152+
"no priority band control plane should exist when the gate is off")
153+
}
154+
})
155+
}
156+
}
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package requestcontrol
18+
19+
import (
20+
"context"
21+
"testing"
22+
"time"
23+
24+
"github.com/go-logr/logr"
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
28+
errcommon "github.com/llm-d/llm-d-router/pkg/common/error"
29+
logutil "github.com/llm-d/llm-d-router/pkg/common/observability/logging"
30+
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/contracts/mocks"
31+
fccontroller "github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/controller"
32+
fcregistry "github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/registry"
33+
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
34+
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
35+
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
36+
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/fairness/globalstrict"
37+
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/ordering/fcfs"
38+
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits"
39+
"github.com/llm-d/llm-d-router/pkg/epp/handlers"
40+
testutils "github.com/llm-d/llm-d-router/test/utils"
41+
)
42+
43+
// This file covers the admission-to-controller seam with a REAL FlowController and FlowRegistry
44+
// behind FlowControlAdmissionController, complementing admission_test.go (which mocks the flow
45+
// controller) and the flowcontrol integration suite (which enters at EnqueueAndWait). It asserts
46+
// the externally visible contract of Admit for every terminal flow control outcome: the
47+
// errcommon.Error code and the x-llm-d-request-dropped-reason header.
48+
49+
// realFlowControlHarness wires a FlowControlAdmissionController to a real FlowController and
50+
// FlowRegistry, following the construction pattern of the flowcontrol integration suite
51+
// (pkg/epp/flowcontrol/integration_helpers_test.go).
52+
type realFlowControlHarness struct {
53+
cancel context.CancelFunc
54+
ac *FlowControlAdmissionController
55+
reg *fcregistry.FlowRegistry
56+
}
57+
58+
// realFlowControlOpts selects the knobs each outcome needs: a fixed saturation level (1.0 keeps
59+
// requests queued), the candidate pool (empty vs. non-empty steers capacity-rejection
60+
// classification), the controller-default request TTL, and an optional per-band request cap.
61+
type realFlowControlOpts struct {
62+
saturation float64
63+
candidates []fwkdl.Endpoint
64+
requestTTL time.Duration // Defaults to one minute (effectively "never" for these tests).
65+
bandMaxRequests uint64 // Zero means unbounded.
66+
}
67+
68+
func newRealFlowControlHarness(t *testing.T, opts realFlowControlOpts) *realFlowControlHarness {
69+
t.Helper()
70+
ctx, cancel := context.WithCancel(context.Background())
71+
t.Cleanup(cancel)
72+
73+
handle := testutils.NewTestHandle(ctx)
74+
orderingPlugin, err := fcfs.FCFSOrderingPolicyFactory("fcfs", nil, handle)
75+
require.NoError(t, err)
76+
fairnessPlugin, err := globalstrict.GlobalStrictFairnessPolicyFactory("global-strict", nil, handle)
77+
require.NoError(t, err)
78+
defaults := fcregistry.PriorityBandPolicyDefaults{
79+
OrderingPolicy: orderingPlugin.(flowcontrol.OrderingPolicy),
80+
FairnessPolicy: fairnessPlugin.(flowcontrol.FairnessPolicy),
81+
}
82+
83+
bandOpts := []fcregistry.PriorityBandConfigOption{fcregistry.WithBandMaxBytes(10_000_000)}
84+
if opts.bandMaxRequests > 0 {
85+
bandOpts = append(bandOpts, fcregistry.WithBandMaxRequests(opts.bandMaxRequests))
86+
}
87+
band, err := fcregistry.NewPriorityBandConfig(0, defaults, bandOpts...)
88+
require.NoError(t, err)
89+
regCfg, err := fcregistry.NewConfig(defaults, fcregistry.WithPriorityBand(band))
90+
require.NoError(t, err)
91+
reg := fcregistry.NewFlowRegistry(regCfg, logr.Discard())
92+
go reg.RunMaintenanceLoop(ctx)
93+
94+
requestTTL := opts.requestTTL
95+
if requestTTL == 0 {
96+
requestTTL = time.Minute
97+
}
98+
detector := &mockSaturationDetector{
99+
SaturationFunc: func(context.Context, []fwkdl.Endpoint) float64 { return opts.saturation },
100+
}
101+
fc := fccontroller.NewFlowController(ctx, "test-pool", &fccontroller.Config{
102+
DefaultRequestTTL: requestTTL,
103+
ExpiryCleanupInterval: 10 * time.Millisecond,
104+
EnqueueChannelBufferSize: 100,
105+
}, fccontroller.Deps{
106+
Registry: reg,
107+
SaturationDetector: detector,
108+
EndpointCandidates: &mocks.MockEndpointCandidates{Candidates: opts.candidates},
109+
UsageLimitPolicy: usagelimits.DefaultPolicy(),
110+
})
111+
112+
return &realFlowControlHarness{
113+
cancel: cancel,
114+
ac: NewFlowControlAdmissionController(fc, "test-pool"),
115+
reg: reg,
116+
}
117+
}
118+
119+
// newSeamRequestContext builds the minimal RequestContext the admission controller adapts into a
120+
// FlowControlRequest. FairnessID must be non-empty: the registry rejects empty flow IDs.
121+
func newSeamRequestContext(id string) *handlers.RequestContext {
122+
return &handlers.RequestContext{
123+
SchedulingRequest: &fwksched.InferenceRequest{RequestID: id, FairnessID: "seam-flow"},
124+
Request: &handlers.Request{Metadata: map[string]any{}},
125+
RequestSize: 100,
126+
RequestReceivedTimestamp: time.Now(),
127+
IncomingModelName: "test-model",
128+
}
129+
}
130+
131+
// admitAsync runs Admit in a goroutine so the test can drive queue state (e.g. cancel a context
132+
// or submit an overflow request) while the call blocks inside EnqueueAndWait.
133+
func admitAsync(ctx context.Context, ac *FlowControlAdmissionController, id string) <-chan error {
134+
result := make(chan error, 1)
135+
go func() { result <- ac.Admit(ctx, newSeamRequestContext(id), 0) }()
136+
return result
137+
}
138+
139+
// waitAdmit unblocks a pending admitAsync result. The generous timeout mirrors the flowcontrol
140+
// integration suite; every path under test finalizes deterministically well before it.
141+
func waitAdmit(t *testing.T, result <-chan error) error {
142+
t.Helper()
143+
select {
144+
case err := <-result:
145+
return err
146+
case <-time.After(10 * time.Second):
147+
t.Fatal("Admit did not return a terminal outcome within 10s")
148+
return nil
149+
}
150+
}
151+
152+
// requireDropped asserts the errcommon.Error contract for a dropped request: the canonical error
153+
// code (which drives the HTTP status) and the dropped-reason response header.
154+
func requireDropped(t *testing.T, err error, wantCode string, wantReason errcommon.RequestDroppedReason) {
155+
t.Helper()
156+
require.Error(t, err)
157+
var e errcommon.Error
158+
require.ErrorAs(t, err, &e, "error should be of type errcommon.Error")
159+
assert.Equal(t, wantCode, e.Code, "incorrect error code")
160+
assert.Equal(t, string(wantReason), e.Headers[errcommon.RequestDroppedReasonHeaderKey],
161+
"incorrect dropped-reason header")
162+
}
163+
164+
func nonEmptyEndpoints() []fwkdl.Endpoint {
165+
return []fwkdl.Endpoint{fwkdl.NewEndpoint(nil, nil)}
166+
}
167+
168+
// TestFlowControlAdmissionController_RealControllerSeam drives every terminal flow control
169+
// outcome through the real controller stack and asserts the status code + header contract that
170+
// admission_test.go only covers against a mock.
171+
func TestFlowControlAdmissionController_RealControllerSeam(t *testing.T) {
172+
t.Parallel()
173+
ctx := logutil.NewTestLoggerIntoContext(context.Background())
174+
175+
t.Run("dispatched_returns_nil", func(t *testing.T) {
176+
t.Parallel()
177+
// Unsaturated detector + non-empty pool: the dispatch cycle admits immediately.
178+
h := newRealFlowControlHarness(t, realFlowControlOpts{
179+
saturation: 0.0,
180+
candidates: nonEmptyEndpoints(),
181+
})
182+
183+
err := waitAdmit(t, admitAsync(ctx, h.ac, "dispatch-req"))
184+
require.NoError(t, err, "an unsaturated system should admit the request")
185+
})
186+
187+
t.Run("rejected_capacity_returns_429_saturated", func(t *testing.T) {
188+
t.Parallel()
189+
// A saturated detector parks a filler request in the only queue slot (bandMaxRequests=1);
190+
// the next request overflows band capacity against a non-empty pool -> RejectedCapacity.
191+
h := newRealFlowControlHarness(t, realFlowControlOpts{
192+
saturation: 1.0,
193+
candidates: nonEmptyEndpoints(),
194+
bandMaxRequests: 1,
195+
})
196+
197+
filler := admitAsync(ctx, h.ac, "filler-req")
198+
require.Eventually(t, func() bool { return h.reg.Stats().TotalLen == 1 },
199+
time.Second, time.Millisecond, "filler request should be queued before the overflow request")
200+
201+
err := waitAdmit(t, admitAsync(ctx, h.ac, "overflow-req"))
202+
requireDropped(t, err, errcommon.ResourceExhausted, errcommon.RequestDroppedReasonSaturated)
203+
204+
// Shut the controller down and drain the filler so the goroutine ends inside the test.
205+
h.cancel()
206+
require.Error(t, waitAdmit(t, filler), "queued filler should be evicted on shutdown")
207+
})
208+
209+
t.Run("rejected_no_endpoints_returns_503_no_endpoints", func(t *testing.T) {
210+
t.Parallel()
211+
// Same capacity overflow, but against an EMPTY candidate pool the processor classifies
212+
// the rejection as RejectedNoEndpoints (genuine unavailability, e.g. scale-from-zero).
213+
h := newRealFlowControlHarness(t, realFlowControlOpts{
214+
saturation: 1.0,
215+
candidates: nil,
216+
bandMaxRequests: 1,
217+
})
218+
219+
filler := admitAsync(ctx, h.ac, "filler-req")
220+
require.Eventually(t, func() bool { return h.reg.Stats().TotalLen == 1 },
221+
time.Second, time.Millisecond, "filler request should be queued before the overflow request")
222+
223+
err := waitAdmit(t, admitAsync(ctx, h.ac, "overflow-req"))
224+
requireDropped(t, err, errcommon.ServiceUnavailable, errcommon.RequestDroppedReasonNoEndpoints)
225+
226+
h.cancel()
227+
require.Error(t, waitAdmit(t, filler), "queued filler should be evicted on shutdown")
228+
})
229+
230+
t.Run("evicted_ttl_returns_503_ttl_expired", func(t *testing.T) {
231+
t.Parallel()
232+
// The admission adapter always defers to the controller-default TTL
233+
// (flowControlRequest.InitialEffectiveTTL() == 0), so a short DefaultRequestTTL plus a
234+
// saturated detector forces a TTL eviction of the queued request.
235+
h := newRealFlowControlHarness(t, realFlowControlOpts{
236+
saturation: 1.0,
237+
candidates: nonEmptyEndpoints(),
238+
requestTTL: 100 * time.Millisecond,
239+
})
240+
241+
err := waitAdmit(t, admitAsync(ctx, h.ac, "ttl-req"))
242+
requireDropped(t, err, errcommon.ServiceUnavailable, errcommon.RequestDroppedReasonTTLExpired)
243+
})
244+
245+
t.Run("evicted_context_cancelled_returns_503_context_cancelled", func(t *testing.T) {
246+
t.Parallel()
247+
// Cancelling the caller's context while the request sits in the queue (saturated
248+
// detector, long TTL) simulates a client disconnect.
249+
h := newRealFlowControlHarness(t, realFlowControlOpts{
250+
saturation: 1.0,
251+
candidates: nonEmptyEndpoints(),
252+
})
253+
254+
admitCtx, admitCancel := context.WithCancel(ctx)
255+
defer admitCancel()
256+
result := admitAsync(admitCtx, h.ac, "cancel-req")
257+
require.Eventually(t, func() bool { return h.reg.Stats().TotalLen == 1 },
258+
time.Second, time.Millisecond, "request should be queued before cancelling")
259+
admitCancel()
260+
261+
err := waitAdmit(t, result)
262+
requireDropped(t, err, errcommon.ServiceUnavailable, errcommon.RequestDroppedReasonContextCancelled)
263+
})
264+
265+
t.Run("shutdown_returns_503_shutting_down", func(t *testing.T) {
266+
t.Parallel()
267+
// A request admitted after the controller's context is cancelled is rejected with
268+
// ErrFlowControllerNotRunning, which maps to 503 + the shutting-down reason.
269+
h := newRealFlowControlHarness(t, realFlowControlOpts{
270+
saturation: 0.0,
271+
candidates: nonEmptyEndpoints(),
272+
})
273+
h.cancel()
274+
275+
err := waitAdmit(t, admitAsync(ctx, h.ac, "shutdown-req"))
276+
requireDropped(t, err, errcommon.ServiceUnavailable, errcommon.RequestDroppedReasonShuttingDown)
277+
})
278+
}

0 commit comments

Comments
 (0)