Skip to content

Commit c2328a1

Browse files
committed
feat: enable the flowControl feature gate by default
Flow control becomes the default admission path. Under saturation, requests now queue in the EPP and dispatch by priority as capacity frees, rather than piling into each model server's local queue; queue wait is bounded by a TTL. Sheddable (negative-priority) requests buffer under the same TTL and capacity rules instead of being rejected immediately. Opt out with featureGates: ["flowControl=false"]. - Flip the registered default for the flowControl gate to true and drop the "experimental" wording from the admission-control init logs. - Warn when a flowControl config section is present while the gate is explicitly disabled; everything in it except saturationDetector (which the legacy admission path also uses) is silently ignored otherwise. - Note that ENABLE_EXPERIMENTAL_FLOW_CONTROL_LAYER is now a no-op in its deprecation log and comment (removal tracked with #2065). - Flip the loader tests' registered gate default and pin the hermetic "do not shed" cases to the explicit flowControl=false opt-out, which they were implicitly relying on; keep the lora-affinity case on the default (gate-on) path by using pod queue depths below the saturation threshold. - Document the default, the opt-out, the metrics scrape health dependency, and the tuning knobs in the flow control README and metrics docs. Part of #1187. Fixes #2104. Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent f56f3bd commit c2328a1

8 files changed

Lines changed: 102 additions & 20 deletions

File tree

cmd/epp/runner/runner.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ import (
146146
const (
147147
// enableExperimentalFlowControlLayer defines the environment variable used as a feature flag for the pluggable flow
148148
// control layer.
149-
// DEPRECATION NOTICE - this env var will be removed in the next version as we switch to configuring the EPP using FeatureGates in the config file.
149+
// DEPRECATION NOTICE - the flowControl feature gate is enabled by default, so this env var is a no-op; it will be
150+
// removed in a future release. Use featureGates in the config file (e.g., "flowControl=false") instead.
150151
enableExperimentalFlowControlLayer = "ENABLE_EXPERIMENTAL_FLOW_CONTROL_LAYER"
151152
)
152153

@@ -683,7 +684,7 @@ func (r *Runner) parseConfigurationPhaseOne(ctx context.Context, opts *runserver
683684
}
684685
}
685686

686-
loader.RegisterFeatureGate(flowcontrol.FeatureGate, false)
687+
loader.RegisterFeatureGate(flowcontrol.FeatureGate, true)
687688
loader.RegisterFeatureGate(runserver.HAPopulateNonLeaderDatastoreFeatureGate, true)
688689

689690
r.registerInTreePlugins()
@@ -765,7 +766,7 @@ func (r *Runner) parseConfigurationPhaseTwo(ctx context.Context, rawConfig *conf
765766

766767
func applyDeprecatedEnvFeatureGate(envVar, featureName, featureGate string, rawConfig *configapi.EndpointPickerConfig) {
767768
if _, ok := os.LookupEnv(envVar); ok {
768-
setupLog.Info(fmt.Sprintf("Enabling the experimental %s using environment variables is deprecated and will be removed in next version", featureName))
769+
setupLog.Info(fmt.Sprintf("The %s environment variable is deprecated and will be removed in a future release; the %s is enabled by default and is configured via featureGates in the config file", envVar, featureName))
769770
if env.GetEnvBool(envVar, false, setupLog) {
770771
if rawConfig.FeatureGates == nil {
771772
rawConfig.FeatureGates = make(configapi.FeatureGates, 0)
@@ -890,13 +891,13 @@ func (r *Runner) initAdmissionControl(
890891
endpointCandidates contracts.EndpointCandidates,
891892
) (contracts.EndpointCandidates, requestcontrol.AdmissionController, contracts.PriorityBandControlPlane) {
892893
if !r.featureGates[flowcontrol.FeatureGate] {
893-
setupLog.Info("Experimental Flow Control layer is disabled, using legacy admission control")
894+
setupLog.Info("Flow Control layer is disabled via the flowControl feature gate, using legacy admission control")
894895
return endpointCandidates,
895896
requestcontrol.NewLegacyAdmissionController(eppConfig.SaturationDetector, endpointCandidates),
896897
nil
897898
}
898899
endpointCandidates = requestcontrol.NewCachedEndpointCandidates(ctx, endpointCandidates, 50*time.Millisecond)
899-
setupLog.Info("Initializing experimental Flow Control layer")
900+
setupLog.Info("Initializing Flow Control layer")
900901
registry := fcregistry.NewFlowRegistry(eppConfig.FlowControlConfig.Registry, setupLog)
901902
fc := fccontroller.NewFlowController(
902903
ctx,

docs/metrics.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ are under `llm_d_epp_`; each has a deprecated `llm_d_inference_scheduler_*` twin
204204

205205
### Flow control
206206

207-
Exposed when the `flowControl` feature gate is enabled.
207+
Exposed when the `flowControl` feature gate is enabled, which is the default; opting out via
208+
`featureGates: ["flowControl=false"]` removes these series.
208209

209210
#### `flow_control_request_queue_duration_seconds`
210211

pkg/epp/config/loader/configloader.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ func InstantiateAndConfigure(
192192
if err != nil {
193193
return nil, fmt.Errorf("failed to load flow control config: %w", err)
194194
}
195+
} else if flowControlSettingsConfigured(rawConfig.FlowControl) {
196+
logger.Info("WARNING: the flowControl config section is set but the flowControl feature gate is disabled; its settings (other than saturationDetector) are ignored")
195197
}
196198

197199
parserRegistry, err := buildParserRegistry(rawConfig.RequestHandler.Parsers, handle, logger)
@@ -217,6 +219,19 @@ func InstantiateAndConfigure(
217219
}, nil
218220
}
219221

222+
// flowControlSettingsConfigured reports whether the flowControl config section carries settings
223+
// beyond the saturation detector. The saturation detector is honored by the legacy admission path
224+
// even when the flowControl feature gate is disabled, so it alone does not indicate ignored
225+
// configuration.
226+
func flowControlSettingsConfigured(fc *configapi.FlowControlConfig) bool {
227+
if fc == nil {
228+
return false
229+
}
230+
return fc.MaxBytes != nil || fc.MaxRequests != nil || fc.DefaultRequestTTL != nil ||
231+
fc.DefaultPriorityBand != nil || fc.DefaultNegativePriorityBand != nil ||
232+
len(fc.PriorityBands) > 0 || fc.UsageLimitPolicyPluginRef != ""
233+
}
234+
220235
func decodeRawConfig(configBytes []byte) (*configapi.EndpointPickerConfig, error) {
221236
cfg := &configapi.EndpointPickerConfig{}
222237
codecs := serializer.NewCodecFactory(scheme, serializer.EnableStrict)

pkg/epp/config/loader/configloader_test.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func TestLoadRawConfiguration(t *testing.T) {
8080

8181
// Register known feature gates for validation.
8282
RegisterFeatureGate(testFeatureGate, true)
83-
RegisterFeatureGate(flowcontrol.FeatureGate, false)
83+
RegisterFeatureGate(flowcontrol.FeatureGate, true)
8484

8585
queueScorerWeight := 2.0
8686
kvCacheUtilizationScorerWeight := 2.0
@@ -189,7 +189,7 @@ func TestLoadRawConfiguration(t *testing.T) {
189189
},
190190
wantFeatures: map[string]bool{
191191
testFeatureGate: false,
192-
flowcontrol.FeatureGate: false,
192+
flowcontrol.FeatureGate: true,
193193
},
194194
wantErr: false,
195195
deprecated: false,
@@ -257,7 +257,7 @@ func TestLoadRawConfiguration(t *testing.T) {
257257
},
258258
wantFeatures: map[string]bool{
259259
testFeatureGate: true,
260-
flowcontrol.FeatureGate: false,
260+
flowcontrol.FeatureGate: true,
261261
},
262262
wantErr: false,
263263
deprecated: false,
@@ -444,7 +444,7 @@ func TestInstantiateAndConfigure(t *testing.T) {
444444
registerTestPlugins(t)
445445

446446
RegisterFeatureGate(testFeatureGate, true)
447-
RegisterFeatureGate(flowcontrol.FeatureGate, false)
447+
RegisterFeatureGate(flowcontrol.FeatureGate, true)
448448

449449
tests := []struct {
450450
name string
@@ -561,7 +561,7 @@ func TestInstantiateAndConfigure(t *testing.T) {
561561
},
562562
},
563563
{
564-
name: "Ignored - Flow Control Config Present but FeatureGate Missing",
564+
name: "Ignored - Flow Control Config Present but FeatureGate Disabled",
565565
configText: successflowControlConfigDisabledText,
566566
wantErr: false,
567567
validate: func(t *testing.T, handle fwkplugin.Handle, rawCfg *configapi.EndpointPickerConfig, cfg *config.Config) {
@@ -850,6 +850,27 @@ func TestInstantiateAndConfigure(t *testing.T) {
850850
}
851851
}
852852

853+
// TestFlowControlConfigIgnoredWarning verifies that a flowControl config section combined with a
854+
// disabled flowControl feature gate logs a warning that the settings are ignored.
855+
func TestFlowControlConfigIgnoredWarning(t *testing.T) {
856+
// Not parallel because it modifies the global plugin registry.
857+
registerTestPlugins(t)
858+
RegisterFeatureGate(flowcontrol.FeatureGate, true)
859+
860+
writer := &strings.Builder{}
861+
logger := logging.NewTestLoggerWithWriter(writer)
862+
863+
rawConfig, _, err := LoadRawConfig([]byte(successflowControlConfigDisabledText), logger)
864+
require.NoError(t, err)
865+
866+
handle := testutils.NewTestHandle(context.Background())
867+
cfg, err := InstantiateAndConfigure(rawConfig, handle, logger)
868+
require.NoError(t, err)
869+
require.Nil(t, cfg.FlowControlConfig, "flow control config should not be built when the gate is disabled")
870+
require.Contains(t, writer.String(), "flowControl feature gate is disabled",
871+
"the ignored flowControl section should be called out in the logs")
872+
}
873+
853874
// TestBuildDataLayerConfigEmptySourcesWarning verifies that an empty sources list
854875
// logs a warning but does not return an error.
855876
func TestBuildDataLayerConfigEmptySourcesWarning(t *testing.T) {

pkg/epp/config/loader/testdata_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ schedulingProfiles:
274274
- name: default
275275
plugins:
276276
- pluginRef: maxScore
277-
featureGates: [] # Explicitly empty
277+
featureGates: ["flowControl=false"] # Explicit opt-out; the gate is enabled by default.
278278
flowControl:
279279
maxBytes: "1024"
280280
`

pkg/epp/flowcontrol/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,33 @@ between the Routing and Scheduling layers. It decides *if* and *when* a request,
3030
mechanism for managing diverse SLOs, ensuring fairness among competing workloads, and maintaining system stability under
3131
high load.
3232

33+
### Enabling, disabling, and tuning
34+
35+
Flow control is enabled by default via the `flowControl` feature gate. With it on, requests queue
36+
under saturation and are admitted by priority instead of being rejected outright; sheddable
37+
(negative-priority) requests buffer until TTL or capacity limits are hit rather than shedding
38+
immediately on saturation. To restore the legacy saturation-only admission behavior, opt out in the
39+
EPP config:
40+
41+
```yaml
42+
featureGates: ["flowControl=false"]
43+
```
44+
45+
Setting a `flowControl:` config section while the gate is disabled logs a warning: everything in it
46+
except `saturationDetector` (which the legacy admission path also uses) is ignored.
47+
48+
Operationally, dispatch decisions depend on fresh endpoint metrics: saturation detection reads the
49+
scraped model-server metrics, so a broken or stale scrape path degrades or halts dispatch. Keep
50+
metrics scrape health monitored when running with flow control on.
51+
52+
Tuning knobs, all under the `flowControl:` config section:
53+
54+
* Per-band `maxRequests` / `maxBytes` — lower these to shed excess load earlier instead of queueing
55+
it (for example, to approximate the legacy immediate-shed behavior for sheddable traffic).
56+
* `defaultRequestTTL` — bounds how long a request may wait in queue before it is evicted.
57+
* The priority-holdback usage-limit policy — gates low-priority traffic earlier as utilization
58+
rises; configure it via `usageLimitPolicyPluginRef`.
59+
3360
### High Level Architecture
3461

3562
The following diagram illustrates the high-level dependency model and request flow for the system. It shows how

test/integration/epp/grpc_test.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ func TestFullDuplexStreamed_GRPC_KubeInferenceObjectiveRequest(t *testing.T) {
6969
// requiresCRDs indicates that this test case relies on specific Gateway API CRD features (like
7070
// InferenceModelRewrite) which are not available in Standalone runMode without CRD.
7171
requiresCRDs bool
72+
// configText overrides testConfigWithVllmGRPCParser for this case if non-empty.
73+
configText string
7274
}{
7375
// --- Standard Routing Logic ---
7476
{
@@ -114,8 +116,12 @@ func TestFullDuplexStreamed_GRPC_KubeInferenceObjectiveRequest(t *testing.T) {
114116
},
115117
},
116118
{
117-
name: "do not shed requests by default",
118-
requests: integration.ReqGRPCLLM(logger, "test2", "", integration.GenerateGRPCMethodName),
119+
// With the flowControl gate on (the default), a request under full saturation queues
120+
// until capacity frees instead of routing immediately, so this case pins the legacy
121+
// opt-out path via flowControl=false.
122+
name: "do not shed requests under saturation with flow control opted out",
123+
configText: testConfigWithVllmGRPCParser + "\nfeatureGates: [\"flowControl=false\"]\n",
124+
requests: integration.ReqGRPCLLM(logger, "test2", "", integration.GenerateGRPCMethodName),
119125
pods: []PodState{
120126
P(0, 6, 0.2, "foo", "bar"), // Winner (Lowest saturated)
121127
P(1, 0, 0.85, "foo"),
@@ -461,7 +467,11 @@ func TestFullDuplexStreamed_GRPC_KubeInferenceObjectiveRequest(t *testing.T) {
461467
t.Run(tc.name, func(t *testing.T) {
462468
ctx := t.Context()
463469

464-
h := NewTestHarness(ctx, t, WithStandardMode(), WithConfigText(testConfigWithVllmGRPCParser)).WithBaseResources()
470+
configText := testConfigWithVllmGRPCParser
471+
if tc.configText != "" {
472+
configText = tc.configText
473+
}
474+
h := NewTestHarness(ctx, t, WithStandardMode(), WithConfigText(configText)).WithBaseResources()
465475

466476
h.WithPods(tc.pods).WaitForSync(len(tc.pods), modelMyModel)
467477
if len(tc.pods) > 0 {

test/integration/epp/hermetic_test.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,13 @@ func TestFullDuplexStreamed_KubeInferenceObjectiveRequest(t *testing.T) {
145145
{
146146
name: "select lora despite higher kv cache (affinity)",
147147
requests: integration.ReqLLM(logger, "test3", modelSQLLora, modelSQLLoraTarget),
148+
// Queue depth is equal across pods so only KV and affinity differentiate, and
149+
// stays below the default saturation queueDepthThreshold (5) so the request
150+
// dispatches immediately with flow control on (the default).
148151
pods: []PodState{
149-
P(0, 10, 0.2, "foo", "bar"),
150-
P(1, 10, 0.4, "foo", modelSQLLoraTarget), // Winner (Affinity overrides KV)
151-
P(2, 10, 0.3, "foo"),
152+
P(0, 4, 0.2, "foo", "bar"),
153+
P(1, 4, 0.4, "foo", modelSQLLoraTarget), // Winner (Affinity overrides KV)
154+
P(2, 4, 0.3, "foo"),
152155
},
153156
wantResponses: ExpectRouteTo("192.168.1.2:8000", modelSQLLoraTarget, "test3"),
154157
wantMetrics: map[string]string{
@@ -197,8 +200,12 @@ dataLayer:
197200
},
198201
},
199202
{
200-
name: "do not shed requests by default",
201-
requests: integration.ReqLLM(logger, "test4", modelSQLLora, modelSQLLoraTarget),
203+
// With the flowControl gate on (the default), a request under full saturation
204+
// queues until capacity frees instead of routing immediately, so this case pins
205+
// the legacy opt-out path via flowControl=false.
206+
name: "do not shed requests under saturation with flow control opted out",
207+
configText: testDLConfig + "\nfeatureGates: [\"flowControl=false\"]\n",
208+
requests: integration.ReqLLM(logger, "test4", modelSQLLora, modelSQLLoraTarget),
202209
pods: []PodState{
203210
P(0, 6, 0.2, "foo", "bar", modelSQLLoraTarget), // Winner (Lowest saturated)
204211
P(1, 0, 0.85, "foo"),

0 commit comments

Comments
 (0)