Skip to content
2 changes: 2 additions & 0 deletions cmd/epp/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import (
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/saturationdetector/concurrency"
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization"
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits"
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback"
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/admitter/latencyslo"
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/admitter/probabilisticadmitter"
reqdataprodprefix "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/dataproducer/approximateprefix"
Expand Down Expand Up @@ -591,6 +592,7 @@ func (r *Runner) registerInTreePlugins() {
fwkplugin.Register(edf.EDFOrderingPolicyType, edf.EDFOrderingPolicyFactory)
fwkplugin.Register(slodeadline.SLODeadlineOrderingPolicyType, slodeadline.SLODeadlineOrderingPolicyFactory)
fwkplugin.Register(usagelimits.StaticUsageLimitPolicyType, usagelimits.StaticPolicyFactory)
fwkplugin.Register(priorityholdback.PolicyType, priorityholdback.PolicyFactory)

// Register Request level data producer plugins as defaults for their respective data keys.
fwkplugin.RegisterAsDefaultProducer(reqdataprodprefix.ApproxPrefixCachePluginType, reqdataprodprefix.ApproxPrefixCacheFactory, attrprefix.PrefixCacheMatchInfoDataKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ flowControl:
---

## Related Documentation
- [Priority Holdback Policy](priorityholdback/README.md)
- [Flow Control Overview](../fairness/README.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Priority Holdback Policy Plugin

**Type:** `priority-holdback-policy`

A usage limit policy that computes differentiated admission ceilings per priority level. As pool saturation rises, lower-priority traffic is gated first, reserving capacity for higher-priority work.

This can be used in place the default static usage limit policy (which applies a single ceiling to all priorities) with priority-aware stepped gating.

## What It Does

Each active priority level receives an admission ceiling in [0.0, 1.0]. During each dispatch cycle, the Flow Controller compares current pool saturation against each priority's ceiling. When saturation exceeds a priority's ceiling, that priority's traffic is held back.

Higher-priority traffic continues to flow until saturation reaches its own (higher) ceiling, providing quality-of-service differentiation under load.

## Configuration

Behavior is configured via two independent parameters: `shape` and `domain`. The resulting ceilings always range from `maxCeiling` for the highest priority to `minCeiling` for the lowest.

### `shape`

The interpolation curve used to distribute ceilings across the range. Currently only `"linear"` is supported.

### `domain`

How priority levels are mapped to positions in the ceiling range.

#### `rank` (default)

Distributes ceilings in equal steps by ordinal rank, ignoring numerical priority values. Ceilings are evenly spaced across `[minCeiling, maxCeiling]`.

c_i = maxCeiling - i * (maxCeiling - minCeiling) / (N - 1)

Where `i` is the index in descending priority order (0 = highest) and `N` is the count of active priorities.

Use when priorities represent ordinal categories (e.g., "critical", "normal", "batch") where the numerical values are arbitrary labels.

#### `value`

Scales ceilings proportionally to the numerical priority value within the observed active range.

r_i = (p_i - pMin) / (pMax - pMin)
c_i = minCeiling + r_i * (maxCeiling - minCeiling)

Use when the numerical spacing between priority values carries meaning and priorities that are numerically close should behave similarly under pressure.

**Parameters:**

- `shape` (string, optional, default: `"linear"`): Interpolation curve. Currently only `"linear"` is supported.
- `domain` (string, optional, default: `"rank"`): Priority mapping: `"rank"` or `"value"`.
- `minCeiling` (float64, required, no default): Ceiling for the lowest priority. Must be in `[0.0, 1.0)`.
- `maxCeiling` (float64, optional, default: `1.0`): Ceiling for the highest priority. Must be in `(0.0, 1.0]`.

`minCeiling` is required because it determines how aggressively low-priority traffic is gated and there is no universally correct default.

**Configuration Example:**
```yaml
plugins:
- type: priority-holdback-policy
name: my-holdback-policy
parameters:
shape: linear
domain: rank
minCeiling: 0.4
maxCeiling: 0.9
flowControl:
usageLimitPolicyPluginRef: my-holdback-policy
```

---

## Related Documentation
- [Static Usage Limit Policy](../README.md)
- [Flow Control Overview](../../fairness/README.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
Copyright 2026 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package priorityholdback

import (
"errors"
"fmt"

"k8s.io/utils/ptr"
)

// shape constants define the interpolation curve applied across the ceiling range.
// Only "linear" is supported; additional shapes (sigmoid, exponential, step, etc.) may be added.
const (
shapeLinear = "linear"
)

// domain constants define how priority levels are mapped to positions in the ceiling range.
const (
// domainRank maps by ordinal rank, ignoring numerical priority values.
domainRank = "rank"
// domainValue maps proportionally to numerical priority values.
domainValue = "value"
)

const (
defaultShape = shapeLinear
defaultDomain = domainRank
defaultMaxCeiling float64 = 1.0
)

// apiConfig represents the external configuration schema for the priority holdback policy.
// It is designed to be deserialized from JSON via the plugin's raw parameters.
type apiConfig struct {
// Shape selects the interpolation curve used to distribute ceilings across the range.
//
// Optional, defaults to "linear". Currently only "linear" is supported.
Shape *string `json:"shape,omitempty"`

// Domain selects how priority levels are mapped to positions in the ceiling range.
// - "rank": equal spacing by ordinal rank, ignoring numerical values.
// - "value": spacing proportional to numerical priority differences.
//
// Optional, defaults to "rank".
Domain *string `json:"domain,omitempty"`

// MinCeiling is the admission ceiling assigned to the lowest-priority traffic.
// Determines how aggressively the lowest priority is gated as saturation rises.
//
// Required. Must be in [0.0, 1.0) and strictly less than MaxCeiling.
MinCeiling *float64 `json:"minCeiling"`

// MaxCeiling is the admission ceiling assigned to the highest-priority traffic.
// A value of 1.0 means the highest priority is only gated at full saturation.
//
// Defaults to 1.0 if unset. Must be in (0.0, 1.0] and strictly greater than MinCeiling.
MaxCeiling *float64 `json:"maxCeiling,omitempty"`
}

// config is the internal, fully-validated configuration used by the policy.
type config struct {
shape string
domain string
minCeiling float64
maxCeiling float64
}

// buildConfig applies the configuration lifecycle (defaulting and validation) and translates the
// external schema into the internal domain model.
// The provided apiConfig is copied to prevent mutation side-effects.
func buildConfig(apiCfg *apiConfig) (*config, error) {
var safeCfg apiConfig
if apiCfg != nil {
safeCfg = *apiCfg
}

if err := checkRequired(&safeCfg); err != nil {
return nil, fmt.Errorf("invalid priority holdback policy configuration: %w", err)
}

applyDefaults(&safeCfg)

if err := validateConfig(&safeCfg); err != nil {
return nil, fmt.Errorf("invalid priority holdback policy configuration: %w", err)
}

return &config{
shape: *safeCfg.Shape,
domain: *safeCfg.Domain,
minCeiling: *safeCfg.MinCeiling,
maxCeiling: *safeCfg.MaxCeiling,
}, nil
}

// checkRequired verifies that mandatory fields are present before defaulting.
func checkRequired(cfg *apiConfig) error {
if cfg.MinCeiling == nil {
return errors.New("minCeiling is required")
}
return nil
}

// applyDefaults populates unset optional fields with their standard defaults.
func applyDefaults(cfg *apiConfig) {
if cfg.Shape == nil {
cfg.Shape = ptr.To(defaultShape)
}
if cfg.Domain == nil {
cfg.Domain = ptr.To(defaultDomain)
}
if cfg.MaxCeiling == nil {
cfg.MaxCeiling = ptr.To(defaultMaxCeiling)
}
}

// validateConfig checks the constraints of the fully defaulted configuration.
// It aggregates all validation failures rather than failing on the first error.
func validateConfig(cfg *apiConfig) error {
var errs []error

if cfg.Shape != nil {
switch *cfg.Shape {
case shapeLinear:
default:
errs = append(errs, fmt.Errorf("unsupported shape %q, must be %q",
*cfg.Shape, shapeLinear))
}
}

if cfg.Domain != nil {
switch *cfg.Domain {
case domainRank, domainValue:
default:
errs = append(errs, fmt.Errorf("unsupported domain %q, must be one of: %q, %q",
*cfg.Domain, domainRank, domainValue))
}
}

if cfg.MinCeiling != nil && (*cfg.MinCeiling < 0.0 || *cfg.MinCeiling >= 1.0) {
errs = append(errs, fmt.Errorf("minCeiling must be in [0.0, 1.0), got %f", *cfg.MinCeiling))
}

if cfg.MaxCeiling != nil && (*cfg.MaxCeiling <= 0.0 || *cfg.MaxCeiling > 1.0) {
errs = append(errs, fmt.Errorf("maxCeiling must be in (0.0, 1.0], got %f", *cfg.MaxCeiling))
}

if cfg.MinCeiling != nil && cfg.MaxCeiling != nil && *cfg.MinCeiling >= *cfg.MaxCeiling {
errs = append(errs, fmt.Errorf("minCeiling (%f) must be strictly less than maxCeiling (%f)",
*cfg.MinCeiling, *cfg.MaxCeiling))
}

return errors.Join(errs...)
}
Loading
Loading