Skip to content

Commit 792e774

Browse files
committed
feat(flowcontrol): reclamation controller for demand-driven in-flight eviction
Implements the controller from docs/flow-control-eviction.md (PR llm-d#2061): on HoL blocking, scan blocked bands for queued demand strictly above the victim head, size revocations as a saturation deficit against a mean-footprint credit with pending-reclaim debits, and pace by confirmation (stream termination) plus a grace covering the sensor's visibility lag, with a timeout so a hung stream cannot wedge the gate. Bands with unattainable ceilings are excluded from demand. Adds the flowControl.enableEviction API field and its translation. New metrics: revocations issued and terminal outcomes, reclaim target, pending reclaim, confirmation latency. Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent f72c2c2 commit 792e774

11 files changed

Lines changed: 1008 additions & 13 deletions

File tree

apix/config/v1alpha1/endpointpickerconfig_types.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,14 @@ type FlowControlConfig struct {
401401
// SaturationDetector specifies which saturation detector plugin to use for both Admission and
402402
// Flow Control. If omitted, "utilization-detector" is used by default.
403403
SaturationDetector *SaturationDetectorConfig `json:"saturationDetector,omitempty"`
404+
405+
// +optional
406+
// EnableEviction enables demand-driven in-flight eviction. When higher-priority requests are
407+
// blocked by pool saturation, lower-priority in-flight requests (priority < 0) may be
408+
// terminated to reclaim capacity. Pacing and sizing self-configure from the selected
409+
// saturation detector. See docs/flow-control-eviction.md.
410+
// Defaults to false.
411+
EnableEviction bool `json:"enableEviction,omitempty"`
404412
}
405413

406414
func (fcc *FlowControlConfig) String() string {
@@ -445,6 +453,10 @@ func (fcc *FlowControlConfig) String() string {
445453
parts = append(parts, fmt.Sprintf("SaturationDetector: %v", fcc.SaturationDetector))
446454
}
447455

456+
if fcc.EnableEviction {
457+
parts = append(parts, "EnableEviction: true")
458+
}
459+
448460
return "{" + strings.Join(parts, ", ") + "}"
449461
}
450462

pkg/epp/flowcontrol/controller/config.go

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ const (
2828
defaultExpiryCleanupInterval = 1 * time.Second
2929
// defaultEnqueueChannelBufferSize is the default size of a worker's incoming request buffer.
3030
defaultEnqueueChannelBufferSize = 100
31+
// defaultMaxRevocationsPerDecision caps revocations per reclamation decision, bounding the
32+
// damage from mean-footprint misestimation.
33+
defaultMaxRevocationsPerDecision = 2
34+
// defaultEvictionConfirmationGrace covers the reclaiming stage (engine abort, KV GC, scrape,
35+
// staleness window) for the default utilization detector. Deployments pairing eviction with the
36+
// concurrency detector can lower it substantially.
37+
defaultEvictionConfirmationGrace = 500 * time.Millisecond
38+
// defaultEvictionConfirmationTimeout bounds how long an unconfirmed revocation can hold the
39+
// reclamation pacing gate closed.
40+
defaultEvictionConfirmationTimeout = 10 * time.Second
3141
)
3242

3343
// Config holds the configuration for the `FlowController`.
@@ -46,6 +56,33 @@ type Config struct {
4656
// serial execution loop and allowing the system to handle short bursts of traffic without blocking.
4757
// Optional: Defaults to `defaultEnqueueChannelBufferSize` (100).
4858
EnqueueChannelBufferSize int
59+
60+
// EnableEviction enables demand-driven in-flight eviction: when higher-priority requests are
61+
// blocked by pool saturation, lower-priority in-flight requests may be terminated to reclaim
62+
// capacity. Requires the eviction plumbing to be wired (see Deps.InFlightEvictor).
63+
// See docs/flow-control-eviction.md.
64+
// Optional: Defaults to false.
65+
EnableEviction bool
66+
67+
// MaxRevocationsPerDecision caps how many revocations a single reclamation decision may issue.
68+
// Not exposed through the API configuration: benchmark data shows sizing is deficit-bound, so
69+
// this cap rarely binds and is not worth a user-facing knob.
70+
// Optional: Defaults to `defaultMaxRevocationsPerDecision` (2).
71+
MaxRevocationsPerDecision int
72+
73+
// EvictionConfirmationGrace is how long a confirmed revocation's pending-reclaim debit keeps
74+
// suppressing further reclamation, covering the saturation signal's confirmation-to-visibility
75+
// lag. Not exposed through the API configuration: the EPP wiring derives it from the selected
76+
// saturation detector, since the correct value is a property of the sensor, not a preference.
77+
// Optional: Defaults to `defaultEvictionConfirmationGrace` (500ms), the conservative value for
78+
// scraped sensors.
79+
EvictionConfirmationGrace time.Duration
80+
81+
// EvictionConfirmationTimeout bounds how long an unconfirmed revocation can hold the
82+
// reclamation pacing gate closed before being treated as confirmed. Not exposed through the
83+
// API configuration.
84+
// Optional: Defaults to `defaultEvictionConfirmationTimeout` (10s).
85+
EvictionConfirmationTimeout time.Duration
4986
}
5087

5188
func (c *Config) String() string {
@@ -64,20 +101,26 @@ type ConfigOption func(*Config)
64101

65102
// NewConfigFromAPI creates a new Config from the API configuration.
66103
func NewConfigFromAPI(apiConfig *configapi.FlowControlConfig) (*Config, error) {
67-
opts := make([]ConfigOption, 0, 1)
104+
opts := make([]ConfigOption, 0, 4)
68105
if apiConfig != nil {
69106
if apiConfig.DefaultRequestTTL != nil {
70107
opts = append(opts, WithDefaultRequestTTL(apiConfig.DefaultRequestTTL.Duration))
71108
}
109+
if apiConfig.EnableEviction {
110+
opts = append(opts, WithEnableEviction(true))
111+
}
72112
}
73113
return NewConfig(opts...)
74114
}
75115

76116
// NewConfig creates a new Config with the given options, applying defaults and validation.
77117
func NewConfig(opts ...ConfigOption) (*Config, error) {
78118
c := &Config{
79-
ExpiryCleanupInterval: defaultExpiryCleanupInterval,
80-
EnqueueChannelBufferSize: defaultEnqueueChannelBufferSize,
119+
ExpiryCleanupInterval: defaultExpiryCleanupInterval,
120+
EnqueueChannelBufferSize: defaultEnqueueChannelBufferSize,
121+
MaxRevocationsPerDecision: defaultMaxRevocationsPerDecision,
122+
EvictionConfirmationGrace: defaultEvictionConfirmationGrace,
123+
EvictionConfirmationTimeout: defaultEvictionConfirmationTimeout,
81124
}
82125

83126
for _, opt := range opts {
@@ -111,6 +154,34 @@ func WithEnqueueChannelBufferSize(size int) ConfigOption {
111154
}
112155
}
113156

157+
// WithEnableEviction enables demand-driven in-flight eviction.
158+
func WithEnableEviction(enabled bool) ConfigOption {
159+
return func(c *Config) {
160+
c.EnableEviction = enabled
161+
}
162+
}
163+
164+
// WithMaxRevocationsPerDecision sets the per-decision revocation cap.
165+
func WithMaxRevocationsPerDecision(n int) ConfigOption {
166+
return func(c *Config) {
167+
c.MaxRevocationsPerDecision = n
168+
}
169+
}
170+
171+
// WithEvictionConfirmationGrace sets the post-confirmation grace period.
172+
func WithEvictionConfirmationGrace(d time.Duration) ConfigOption {
173+
return func(c *Config) {
174+
c.EvictionConfirmationGrace = d
175+
}
176+
}
177+
178+
// WithEvictionConfirmationTimeout sets the confirmation timeout.
179+
func WithEvictionConfirmationTimeout(d time.Duration) ConfigOption {
180+
return func(c *Config) {
181+
c.EvictionConfirmationTimeout = d
182+
}
183+
}
184+
114185
// validate checks the configuration for validity.
115186
func (c *Config) validate() error {
116187
if c.DefaultRequestTTL < 0 {
@@ -122,5 +193,14 @@ func (c *Config) validate() error {
122193
if c.EnqueueChannelBufferSize < 0 {
123194
return fmt.Errorf("EnqueueChannelBufferSize cannot be negative, but got %d", c.EnqueueChannelBufferSize)
124195
}
196+
if c.MaxRevocationsPerDecision < 1 {
197+
return fmt.Errorf("MaxRevocationsPerDecision must be at least 1, but got %d", c.MaxRevocationsPerDecision)
198+
}
199+
if c.EvictionConfirmationGrace < 0 {
200+
return fmt.Errorf("EvictionConfirmationGrace cannot be negative, but got %v", c.EvictionConfirmationGrace)
201+
}
202+
if c.EvictionConfirmationTimeout <= 0 {
203+
return fmt.Errorf("EvictionConfirmationTimeout must be positive, but got %v", c.EvictionConfirmationTimeout)
204+
}
125205
return nil
126206
}

pkg/epp/flowcontrol/controller/config_test.go

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ func TestNewConfig(t *testing.T) {
4141
opts: nil,
4242
expectErr: false,
4343
expectedCfg: Config{
44-
DefaultRequestTTL: 0,
45-
ExpiryCleanupInterval: defaultExpiryCleanupInterval,
46-
EnqueueChannelBufferSize: defaultEnqueueChannelBufferSize,
44+
DefaultRequestTTL: 0,
45+
ExpiryCleanupInterval: defaultExpiryCleanupInterval,
46+
EnqueueChannelBufferSize: defaultEnqueueChannelBufferSize,
47+
MaxRevocationsPerDecision: defaultMaxRevocationsPerDecision,
48+
EvictionConfirmationGrace: defaultEvictionConfirmationGrace,
49+
EvictionConfirmationTimeout: defaultEvictionConfirmationTimeout,
4750
},
4851
},
4952
{
@@ -53,9 +56,12 @@ func TestNewConfig(t *testing.T) {
5356
},
5457
expectErr: false,
5558
expectedCfg: Config{
56-
DefaultRequestTTL: 10 * time.Second,
57-
ExpiryCleanupInterval: defaultExpiryCleanupInterval,
58-
EnqueueChannelBufferSize: defaultEnqueueChannelBufferSize,
59+
DefaultRequestTTL: 10 * time.Second,
60+
ExpiryCleanupInterval: defaultExpiryCleanupInterval,
61+
EnqueueChannelBufferSize: defaultEnqueueChannelBufferSize,
62+
MaxRevocationsPerDecision: defaultMaxRevocationsPerDecision,
63+
EvictionConfirmationGrace: defaultEvictionConfirmationGrace,
64+
EvictionConfirmationTimeout: defaultEvictionConfirmationTimeout,
5965
},
6066
},
6167
{
@@ -67,9 +73,12 @@ func TestNewConfig(t *testing.T) {
6773
},
6874
expectErr: false,
6975
expectedCfg: Config{
70-
DefaultRequestTTL: 10 * time.Second,
71-
ExpiryCleanupInterval: 2 * time.Second,
72-
EnqueueChannelBufferSize: 50,
76+
DefaultRequestTTL: 10 * time.Second,
77+
ExpiryCleanupInterval: 2 * time.Second,
78+
EnqueueChannelBufferSize: 50,
79+
MaxRevocationsPerDecision: defaultMaxRevocationsPerDecision,
80+
EvictionConfirmationGrace: defaultEvictionConfirmationGrace,
81+
EvictionConfirmationTimeout: defaultEvictionConfirmationTimeout,
7382
},
7483
},
7584
{
@@ -100,6 +109,45 @@ func TestNewConfig(t *testing.T) {
100109
},
101110
expectErr: true,
102111
},
112+
{
113+
name: "WithEvictionOptions_ShouldUpdateConfig",
114+
opts: []ConfigOption{
115+
WithEnableEviction(true),
116+
WithMaxRevocationsPerDecision(5),
117+
WithEvictionConfirmationGrace(50 * time.Millisecond),
118+
WithEvictionConfirmationTimeout(30 * time.Second),
119+
},
120+
expectErr: false,
121+
expectedCfg: Config{
122+
ExpiryCleanupInterval: defaultExpiryCleanupInterval,
123+
EnqueueChannelBufferSize: defaultEnqueueChannelBufferSize,
124+
EnableEviction: true,
125+
MaxRevocationsPerDecision: 5,
126+
EvictionConfirmationGrace: 50 * time.Millisecond,
127+
EvictionConfirmationTimeout: 30 * time.Second,
128+
},
129+
},
130+
{
131+
name: "ZeroMaxRevocationsPerDecision_ShouldError",
132+
opts: []ConfigOption{
133+
WithMaxRevocationsPerDecision(0),
134+
},
135+
expectErr: true,
136+
},
137+
{
138+
name: "NegativeEvictionConfirmationGrace_ShouldError",
139+
opts: []ConfigOption{
140+
WithEvictionConfirmationGrace(-1 * time.Second),
141+
},
142+
expectErr: true,
143+
},
144+
{
145+
name: "ZeroEvictionConfirmationTimeout_ShouldError",
146+
opts: []ConfigOption{
147+
WithEvictionConfirmationTimeout(0),
148+
},
149+
expectErr: true,
150+
},
103151
}
104152

105153
for _, tc := range testCases {
@@ -175,6 +223,19 @@ func TestNewConfigFromAPI(t *testing.T) {
175223
},
176224
expectedErr: "DefaultRequestTTL cannot be negative",
177225
},
226+
{
227+
name: "EnableEviction_ShouldTranslate_WithInternalDefaults",
228+
apiConfig: &configapi.FlowControlConfig{
229+
EnableEviction: true,
230+
},
231+
assertion: func(t *testing.T, cfg *Config) {
232+
assert.True(t, cfg.EnableEviction, "EnableEviction should be translated")
233+
// Pacing and sizing parameters are internal: defaulted here, derived at wiring time.
234+
assert.Equal(t, defaultMaxRevocationsPerDecision, cfg.MaxRevocationsPerDecision)
235+
assert.Equal(t, defaultEvictionConfirmationGrace, cfg.EvictionConfirmationGrace)
236+
assert.Equal(t, defaultEvictionConfirmationTimeout, cfg.EvictionConfirmationTimeout)
237+
},
238+
},
178239
}
179240

180241
for _, tc := range testCases {

pkg/epp/flowcontrol/controller/controller.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type processorFactory func(
6060
cleanupSweepInterval time.Duration,
6161
enqueueChannelBufferSize int,
6262
logger logr.Logger,
63+
reclamation *internal.ReclamationController,
6364
) processor
6465

6566
var _ processor = &internal.Processor{}
@@ -105,6 +106,11 @@ type Deps struct {
105106
UsageLimitPolicy flowcontrol.UsageLimitPolicy
106107
Clock clock.WithTicker
107108
ProcessorFactory processorFactory
109+
110+
// InFlightEvictor enables demand-driven in-flight eviction when Config.EnableEviction is set.
111+
// Satisfied by *eviction.RequestEvictor. The FlowController registers the reclamation
112+
// controller's Confirm as the evictor's eviction-terminated listener.
113+
InFlightEvictor internal.InFlightEvictor
108114
}
109115

110116
// NewFlowController creates and starts a new FlowController instance.
@@ -147,6 +153,7 @@ func NewFlowController(
147153
cleanupSweepInterval time.Duration,
148154
enqueueChannelBufferSize int,
149155
logger logr.Logger,
156+
reclamation *internal.ReclamationController,
150157
) processor {
151158
return internal.NewProcessor(
152159
ctx,
@@ -160,12 +167,36 @@ func NewFlowController(
160167
cleanupSweepInterval,
161168
enqueueChannelBufferSize,
162169
logger,
170+
reclamation,
163171
)
164172
}
165173
} else {
166174
fc.processorFactory = deps.ProcessorFactory
167175
}
168176

177+
// Demand-driven in-flight eviction is active only when both the config enables it and the
178+
// eviction plumbing was wired in. The reclamation controller's Confirm becomes the evictor's
179+
// eviction-terminated listener, closing the confirmation loop.
180+
var reclamation *internal.ReclamationController
181+
if config.EnableEviction && deps.InFlightEvictor != nil {
182+
reclamation = internal.NewReclamationController(
183+
internal.ReclamationConfig{
184+
MaxRevocationsPerDecision: config.MaxRevocationsPerDecision,
185+
ConfirmationGrace: config.EvictionConfirmationGrace,
186+
ConfirmationTimeout: config.EvictionConfirmationTimeout,
187+
},
188+
deps.InFlightEvictor,
189+
deps.Clock,
190+
fc.logger,
191+
poolName,
192+
)
193+
deps.InFlightEvictor.SetEvictionTerminatedListener(reclamation.Confirm)
194+
fc.logger.V(logutil.DEFAULT).Info("Demand-driven in-flight eviction enabled.",
195+
"maxRevocationsPerDecision", config.MaxRevocationsPerDecision,
196+
"confirmationGrace", config.EvictionConfirmationGrace,
197+
"confirmationTimeout", config.EvictionConfirmationTimeout)
198+
}
199+
169200
// Construct a new worker, but do not start its goroutine yet.
170201
fc.processor = fc.processorFactory(
171202
fc.parentCtx,
@@ -178,6 +209,7 @@ func NewFlowController(
178209
fc.config.ExpiryCleanupInterval,
179210
fc.config.EnqueueChannelBufferSize,
180211
fc.logger,
212+
reclamation,
181213
)
182214

183215
fc.logger.V(logutil.DEFAULT).Info("Starting the Processor.")

pkg/epp/flowcontrol/controller/controller_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ func (f *mockProcessorFactory) new(
287287
_ time.Duration,
288288
_ int,
289289
_ logr.Logger,
290+
_ *internal.ReclamationController,
290291
) processor {
291292
if f.processor != nil {
292293
return f.processor

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ type Processor struct {
7575
cleanupSweepInterval time.Duration
7676
logger logr.Logger
7777

78+
// reclamation, when non-nil, enables demand-driven in-flight eviction on HoL blocking.
79+
// See docs/flow-control-eviction.md.
80+
reclamation *ReclamationController
81+
7882
// lifecycleCtx controls the processor's lifetime. Monitored by Submit* methods for safe shutdown.
7983
lifecycleCtx context.Context
8084

@@ -106,6 +110,7 @@ func NewProcessor(
106110
cleanupSweepInterval time.Duration,
107111
enqueueChannelBufferSize int,
108112
logger logr.Logger,
113+
reclamation *ReclamationController,
109114
) *Processor {
110115
return &Processor{
111116
registry: registry,
@@ -119,6 +124,7 @@ func NewProcessor(
119124
logger: logger,
120125
lifecycleCtx: ctx,
121126
enqueueChan: make(chan *FlowItem, enqueueChannelBufferSize),
127+
reclamation: reclamation,
122128
}
123129
}
124130

@@ -377,6 +383,9 @@ func (p *Processor) dispatchCycle(ctx context.Context) bool {
377383
if saturation >= usageLimit {
378384
p.logger.V(logutil.DEBUG).Info("Priority band is saturated; enforcing HoL blocking.",
379385
"priority", priority, "saturation", saturation, "usageLimit", usageLimit)
386+
if p.reclamation != nil {
387+
p.maybeReclaim(ctx, saturation, priorities, ceilings, i)
388+
}
380389
// Stop the dispatch cycle entirely to respect strict policy decision and prevent priority inversion where
381390
// lower-priority work might exacerbate the saturation affecting high-priority work.
382391
return false

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ func newTestHarness(t *testing.T, expiryCleanupInterval time.Duration) *testHarn
142142
h.clock,
143143
expiryCleanupInterval,
144144
100,
145-
h.logger)
145+
h.logger,
146+
nil)
146147
require.NotNil(t, h.processor, "NewProcessor should not return nil")
147148

148149
t.Cleanup(func() { h.Stop() })

0 commit comments

Comments
 (0)